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..7506ca5eb0 --- /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 and how to file it; [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. +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-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 4bdeb727fe..2832af8cda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,197 +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 Code**, DeepSeek's coding agent product. 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 taxonomy, 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 + fs tools - (read/write/edit) + 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 @@ -211,83 +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. -- **Opaque cross-boundary ids are branded, never bare `string`**: an identity that crosses a package seam and that a consumer must store-and-return but never parse (a backend-defined version token, a target key, a task/session/call id) is a `Branded` from `@deepseek-ai/dsh-brand` with a same-named cast factory in the owning package — a zero-cost compile-time guard so semantically-distinct strings stop being interchangeable. Not every string needs it: author-readable names (`ToolName`) and closed code unions (`ErrorCode`) don't. See [Branded IDs everywhere they belong](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md). -- **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. -- **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. -- **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/architecture.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/acp/snapshot-replay.md b/docs/acp/snapshot-replay.md new file mode 100644 index 0000000000..4242f2406d --- /dev/null +++ b/docs/acp/snapshot-replay.md @@ -0,0 +1,27 @@ + + +# 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 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. + +Maintenance mode: curated Mermaid sequence based on the snapshot test harness. 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 bb37c40c71..52e5bc4439 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,191 +1,38 @@ # 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: +This document describes the architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle: **everything is a plugin**. The core is deliberately tiny — a handful of abstract services plus one concrete loop plugin (`dsh-agent-loop`) — and every product feature is a plugin against the extension surface described here, without modifying the loop. The stack is three tiers: plugins (the loop itself, seam implementations, model-facing tools, bridges) over interface/service packages (each owning one `ctx` key and its vocabulary) over the vendored Cordis kernel (`vendor/`). -> **Microkernel approach. Everything is a plugin.** - -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. - -Requirement context: [Coding Harness MVP 需求分析][mvp-doc]. - -For a catalog of the **data structures** this architecture moves around — the core vocabulary types, their literal shapes, and the seam types grouped by capability — see [core-data-structures/](core-data-structures/core.md). This document covers behavior; that one covers the types. - -**Contents:** [Layering](#layering) · [Service map](#service-map) · [Capability seams](#capability-seams-interface--implementation--consumer) · [The vocabulary (dsh-llm)](#the-vocabulary-dsh-llm) · [Event-sourced sessions](#event-sourced-sessions-dsh-session) · [Prompt assembly](#prompt-assembly-dsh-system-prompt) · [Tool pipeline](#tool-pipeline-dsh-tools) · [Agents and the loop](#agents-dsh-agent-and-the-loop-dsh-agent-loop) ([lifecycle](#loop-lifecycle-session--turn--step), [event taxonomy](#event-taxonomy), [waterfall semantics](#cordis-waterfall-semantics-important)) · [Plugin sanity checklist](#plugin-sanity-checklist) · [Extension cookbook](#extension-cookbook) · [Deferred work](#deferred-work-todo) - -[microkernel-doc]: https://trtgsjkv6r.feishu.cn/wiki/VS9Lw1kQki6mDJk2UHocyuphnsc -[mvp-doc]: https://trtgsjkv6r.feishu.cn/wiki/ZwK6wfBE9i91V6kzMGYcgRGanxg - -## Layering - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 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-fs-local (filesystem impl) │ -│ @deepseek-ai/dsh-fs-policy (filesystem policy gate) │ -│ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│ -│ @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-fs (filesystem provider seam) │ -│ @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 │ -└─────────────────────────────────────────────────────────────┘ -``` - -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. +This document covers **behavior**; type shapes live in [core-data-structures/](core-data-structures/core.md), the per-event/service reference in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, visual relationship maps in the [documentation graph index](graph-atlas.md), and per-package contracts in the package READMEs ([map](../packages/README.md)). ## Service map -| 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.userInteraction` | `UserInteractionService` | dsh-user-interaction | UI-backed human question/answer seam for tools and permission flows | -| `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.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, atomic writes/edits (optional version guard); owns the `fs/*` policy events | -| `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | -| `ctx.subagents` | `SubagentService` | dsh-subagent | named provider registry for delegating a task to child agents | +The spine — the product API under `packages/core/`: -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. +| ctx key | Package | Role | +|---|---|---| +| `ctx.sessions` | dsh-session | creates/holds event-sourced `Session`s | +| `ctx.systemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | +| `ctx.tools` | dsh-tools | tool definitions; `execute()` through waterfall | +| `ctx.agents` | dsh-agent | live `Agent` handles + create/resume factory (returns `AgentHandle { agent, dispose() }`) | +| `ctx.agentLoop` | dsh-agent-loop | THE concrete loop plugin: creates and drives `ReactLoopAgent`s | -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. +The swappable capability seams: -## Capability seams: interface / implementation / consumer +| ctx key | Package | Role | +|---|---|---| +| `ctx.llm` | dsh-llm | adapter registry; `stream()` | +| `ctx.sessionPersistence` | dsh-session-persistence | durable persistence: create/append/load/list | +| `ctx.bash` | dsh-bash | bash execution: foreground runs + background tasks | +| `ctx.fs` | dsh-fs | filesystem provider: read/stream, atomic writes/edits; owns the `fs/*` policy events | +| `ctx.compact` | dsh-compact | compaction: detect pressure, summarize an older range | +| `ctx.web` | dsh-web | search/fetch provider registries + `WebError` taxonomy | +| `ctx.subagents` | dsh-subagent | named provider registry for delegating to child agents | -Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template: +Dependency rule: plugins depend on these interfaces, never on `dsh-agent-loop` — the loop is swappable; the sanctioned exception is the composition bundle `dsh-agent-core`, whose job is assembling the concrete spine ([full rule + generated graph](../packages/README.md#dependencies)). -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. +All registrations go through `ctx.effect()` and return disposers, so hot-reload and fiber disposal clean up automatically (full service interfaces: the generated [services catalog](cordis-catalog/services.md)). -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 filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The demo agents (`coding-agent`, `acp-agent`) wire the full stack — `dsh-fs-local` + `dsh-fs-policy` + `dsh-tool-fs` — so `read`/`write`/`edit` are the default file surface (bash stays for shell/tests/search); the tools resolve a relative path against the caller's session cwd, matching bash ([the per-session cwd RFC](rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)). See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). - -> **"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. - -## The vocabulary (dsh-llm) - -Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings. - -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 })`. 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. - -``` -forever: - wait for queued messages (idle) - 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 - 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 -``` - -Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with `turn/end { reason: { kind: 'error', step, message, code? } }` — the failure's step number rides on the durable turn reason (there is no separate session `error` event); live diagnostics fire via `agent/error`. Never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. - -Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. - -A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. - -**Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). - -### Event taxonomy - -The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package); each other service declares its own events (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The full catalog — every event's exact signature, dispatch mode, and prose — is **generated from source** and lives in [cordis-catalog/events-and-services.md](cordis-catalog/events-and-services.md) (the `## Events` section), alongside the `ctx.` service interfaces. That file is regenerated by `scripts/gen-cordis-catalog.ts` and frozen by the `verify-cordis-catalog` freshness gate (part of `doc-sync`), so it cannot drift from the `interface Events` declarations. - -### Cordis waterfall semantics (important) +## Cordis waterfall semantics `ctx.waterfall` is **around-middleware**, not a value reducer. Each listener receives `(...args, next)`: @@ -193,47 +40,108 @@ The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depend - return a value **without** calling `next()` to short-circuit (veto); - listeners run in registration order; `prepend: true` jumps the queue. -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. +Composition caveat: values propagate through `next()`'s **return value** — a listener that returns a *new* object makes earlier listeners' mutations invisible downstream. Prefer mutate-then-`next()` for cooperative middleware; return a replacement only to take over the result. -## Plugin sanity checklist +## Capability seams: interface / implementation / consumer -Every MVP feature (including the TODO-marked ones), with the mechanism that implements it **without modifying the loop**: +Swappable capabilities split into three packages — **interface** (abstract service + vocabulary, owns the ctx key), **implementation** (a concrete subclass loaded as a plugin), **consumer** (what the model and plugins program against) — so each evolves independently; the bash trio is the template ([capability seams RFC](rfc/implemented/architecture/2026-06-13-capability-seams.md)). Keep interface + consumer together when they are one concern (the LLM seam: `dsh-llm` carries both, adapters implement); don't split preemptively. -| MVP feature | Plugin 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) | -| 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 | `dsh-user-interaction` provides `ctx.userInteraction`; `dsh-tool-ask-user` registers `ask_user_question`; permission plugins can also wrap `tools/execute` and ask before delegating | -| 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 | +Two seams bend the template deliberately: -## Extension cookbook +- **Filesystem** adds a policy layer as an **event gate**, not a method service: `dsh-tool-fs` (the `read`/`write`/`edit` tools AND executor) dispatches `fs/*` intent events that `dsh-fs-policy` decides, so dropping the policy plugin degrades to the bare provider instead of breaking an injection ([event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)). Paths resolve against the caller's session cwd, matching bash ([per-session cwd RFC](rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)). +- **Web** folds search and fetch onto one seam: `ctx.web` is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection); providers register like LLM adapters, and `dsh-tool-web` is the single consumer owning the tool schemas ([web seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.md)). -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). +> The seam pattern is plain Cordis services + `inject` (a consumer's fiber stays pending until the service exists). Despite the name, `@cordisjs/plugin-capability` is unrelated — a permission-security service (a candidate for the deferred permissions work), not a mechanism for swapping implementations. + +## Content blocks and streaming (dsh-llm) + +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`, `TurnEndReason`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction ([the drop-image RFC](rfc/implemented/simplification/2026-07-04-drop-image-content-block.md)). Streaming is a raw chunk protocol (`block-start` … `finish`) with `BlockAssembler` as the single shared chunk→block assembler; the loop logs raw chunks (replay fidelity) while assembling them. `LlmAdapter` is the provider seam: subclass, implement `stream()`, register via `ctx.llm.registerAdapter(models, adapter)`; `dsh-llm-deepseek` and `dsh-llm-pi-ai` implement the one contract as deliberate design twins ([twin RFC](rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)). The StreamChunk conventions (usage/finish ordering, raw-string tool arguments, the two sanctioned error paths) are pinned in `dsh-llm/src/types.ts` and [llm-streaming.md](core-data-structures/llm-streaming.md). + +## 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* (`deriveMessages()`): user/assistant messages, tool results, and envelope-tagged context/steering messages come from their events in chronological order (raw `assistant/chunk` events are replay/UI data, skipped; the per-event mapping is in [session.md](core-data-structures/session.md)). Replay/fork = `ctx.sessions.create(id, { seed })`; trace/telemetry = listen to `session/event` ([event-sourcing RFC](rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md)). + +**Durability**: `session/event` is a synchronous notification; persistence backends buffer write-behind and drain at the awaited `session/flush` checkpoint at every turn end. The abstract `SessionPersistence` seam defines create/append/load/list over `SessionEvent` (no parallel persisted type); metadata travels as `SessionHeader`; crash recovery preserves an interrupted turn by closing it with a synthetic `turn/end {interrupted}`. Two backends (JSONL, SQLite) pass one shared contract suite ([persistence RFC](rfc/implemented/architecture/2026-06-14-session-persistence.md), [write coordinator RFC](rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)). Resume = `ctx.agents.resume({ resumeSessionId })`. + +## Prompt assembly (dsh-system-prompt) + +Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers; `assemble()` returns `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 — though adapters transmit them as the wire-level `tools` field ([RFC](rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md)). + +## Tool pipeline (dsh-tools) + +`ToolRegistry.register()` takes schema + `execute()`; schemas flow into the assembly automatically. `execute()` runs through a two-waterfall pipeline — `tools/pre-execute` (a `PreToolDecision`: allow/deny/ask) → core dispatch → `tools/post-execute` (a `PostToolDecision`: accept/block, replace content, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins live. A thrown tool still reaches `post-execute` as an `isError` result. + +## Agents (dsh-agent) and the loop (dsh-agent-loop) + +`Agent` is the handle every plugin programs against: `send()` (queued), `steer()` (mid-turn injection, drained between steps), `inject()` (in-session context; a one-shot `injection` turn when idle), `cancel()` (the single public stop primitive: clears queued + steering work, aborts the in-flight step, drops a turn about to start), `whenIdle()` (quiescence observation, not teardown), plus `session`/`status`/`options`. A lifecycle owner tears down via `await AgentHandle.dispose()` — stop, await exit, unregister. Full semantics: [core.md](core-data-structures/core.md), [lifecycle RFC](rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). + +**Subagents** are a seam, not a method on `Agent`: `ctx.subagents` is a named-provider registry (`spawn` starts fresh, `fork` seeds the child with the parent's completed-turn prefix, ACP drives an out-of-process child); children are ordinary `Agent`s. See [subagent.md](core-data-structures/subagent.md), [subagent RFC](rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). + +### Loop lifecycle (session / turn / step) + +- **Session**: the whole event log of one agent. +- **Turn**: ≥1 queued message; steps run until the model stops requesting tools and no plugin requests continuation. +- **Step**: one model request + its tool executions. + +``` +create agent → emit agent/session-start(source) ⟵ once, before turn 1 (startup|resume) +forever: + wait for queued messages (idle) + emit agent/status(running) + TURN (error-contained — a throwing plugin ends the turn, never the loop): + 'turn/start' ⟵ durable turn boundary (no agent/* mirror) + each queued msg: waterfall agent/prompt-submit ⟵ allow (rewrite/+context) | block + allow → session('user/message'…); inject additionalContext + every prompt blocked → 'turn/end'(rejected), 0 steps ⟵ zero-step turn, model never called + 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') ⟵ 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') + 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/pre-execute (allow/ + deny/ask gate) → dispatch → tools/post-execute (accept/block, replace, +context) + tool execution may append tool-owned session events, e.g. `todo/write` + session('tool/result') + append buffered post-execute additionalContext → session('context/message')(s) + ⟵ after ALL tool/results (adjacency) + drain steering → session('steering/message') + session('step/end') ⟵ durable step boundary (no agent/* mirror) + cont = waterfall agent/turn-continuation(default = {action: hadToolCalls||steered + ? 'continue' : 'stop'}) → ContinuationDecision + a continue's reason is recorded as next-step steering (same turn); steering pending + also forces continue (continuation OR step/end listeners — the /goal pattern) + if action==stop: break + session('turn/end') ⟵ durable turn boundary (no agent/* mirror) + 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 +``` + +Error containment: a throwing listener or broken step ends the **turn** (`turn/end { reason: { kind: 'error', step, … } }`), never the driver loop; live diagnostics fire via `agent/error`; an adapter's in-band error/aborted finish chunk becomes a step error. `cancel()` is honored mid-stream and between tool calls; disposal mid-turn ends the turn `disposed`. A post-`turn/end` failure (a rejecting `session/flush`) is reported via `agent/error` only — the turn stays balanced, the backend keeps its buffer. + +A turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`; per-variant semantics (and the max-tokens-wins rule) are in [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). + +**Turn-enclosure invariant**: every session event lives inside a turn, making the turn the single durability/replay boundary — anything after the last `turn/end` is an interrupted-crash tail. `dsh-invariants` enforces it in dev ([invariant RFC](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). + +## Event taxonomy + +The `agent/*` events are declared in `dsh-agent` (so nothing depends on the loop package); each other service declares its own (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The full catalog — signatures, dispatch modes, prose — is generated from source and freshness-gated: [cordis-catalog/events.md](cordis-catalog/events.md). Domain semantics (session = the fact log, agent = the live surface): [the event-domain RFC](rfc/implemented/architecture/2026-06-30-event-domain-semantics.md). + +## Extension guide + +Plugin skeletons (tool, hook/permission gate, UI, protocol bridge) and the feature→mechanism map — which extension seam implements each product feature — live in [the extension cookbook](cookbook/extension-cookbook.md); step-by-step guides: [adding a package](cookbook/adding-a-package.md), [a tool](cookbook/adding-a-tool.md), [an LLM adapter](cookbook/adding-an-llm-adapter.md), [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 seed-based forking. +Designed-for but not implemented: inter-agent channels beyond delegation (shared state, streaming output); the model-facing `/compact` consumer tool over `ctx.compact` ([compaction RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)); parallel tool execution (concurrency-safety hints on `ToolDefinition`); session branching/tree if seed-based forking proves insufficient. diff --git a/docs/capability-seams.md b/docs/capability-seams.md new file mode 100644 index 0000000000..cef2f5b76a --- /dev/null +++ b/docs/capability-seams.md @@ -0,0 +1,153 @@ + + +# 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_ask_user["tool-ask-user"] + pkg_tool_bash["tool-bash"] + pkg_tool_subagent["tool-subagent"] + pkg_tool_todo["tool-todo"] + pkg_user_interaction["user-interaction"] + svc_userInteraction["ctx.userInteraction
Human question/answer seam"] + pkg_stdio_agent["stdio-agent"] + svc_agents["ctx.agents
Agent registry"] + 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_acp --> svc_userInteraction + 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_stdio_agent --> svc_userInteraction + 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_user_interaction --> svc_userInteraction + 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_ask_user + 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_userInteraction --> pkg_acp + svc_userInteraction --> pkg_stdio_agent + svc_userInteraction --> pkg_tool_ask_user + 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-ask-user`](../packages/ui/tool-ask-user), [`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.userInteraction` | `seam` | [`user-interaction`](../packages/core/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `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..7995918a94 --- /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 — `gh pr list --json number,baseRefName` first, and merge without `--delete-branch` where a child still bases on the branch. + +## 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 c48465a245..0000000000 --- a/docs/cordis-catalog/events-and-services.md +++ /dev/null @@ -1,596 +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) - -### `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:117`](../../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:129`](../../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:105`](../../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: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.fs` — `FileSystem` (abstract seam) - -Abstract filesystem provider service. Subclass, implement the six text-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`. -- 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 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:158`](../../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:69`](../../packages/llm/llm/src/index.ts) - -### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) - -Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). - -Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF): - -- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. -- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). -- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. -- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). - -```ts cordis-catalog -abstract create(meta: SessionHeader): Promise -abstract append(id: SessionId, events: readonly SessionEvent[]): Promise -abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> -abstract list(): Promise -``` - -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:287`](../../packages/core/tools/src/index.ts) - -### `ctx.userInteraction` — `UserInteractionService` - -`ctx.userInteraction`: one active UI provider plus an `ask()` surface. - -```ts cordis-catalog -registerProvider(provider: UserInteractionProvider): () => void -async ask(request: AskUserQuestionRequest): Promise -``` - -Source: [`packages/core/user-interaction/src/index.ts:70`](../../packages/core/user-interaction/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..b2b2e08858 --- /dev/null +++ b/docs/cordis-catalog/events.md @@ -0,0 +1,334 @@ + + +# 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](../architecture.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:234`](../../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:241`](../../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:380`](../../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:319`](../../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:332`](../../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:259`](../../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:345`](../../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:274`](../../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:250`](../../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:355`](../../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:368`](../../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:77`](../../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:70`](../../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:26`](../../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:32`](../../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..d4a5523fc0 --- /dev/null +++ b/docs/cordis-catalog/services.md @@ -0,0 +1,254 @@ + + +# 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.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:123`](../../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:73`](../../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.userInteraction` — `UserInteractionService` + +`ctx.userInteraction`: one active UI provider plus an `ask()` surface. + +```ts cordis-catalog +registerProvider(provider: UserInteractionProvider): () => void +async ask(request: AskUserQuestionRequest): Promise +``` + +Source: [`packages/core/user-interaction/src/index.ts:70`](../../packages/core/user-interaction/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/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 c3002b7dfc..71a49d3ff1 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -11,19 +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 | -| [user-interaction.md](user-interaction.md) | the human question/answer seam: `AskUserQuestionRequest`/`Answer`, options, provider, structured errors | +| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | +| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [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 | +| [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. @@ -87,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: @@ -108,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 } } ``` @@ -132,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 /** @@ -182,7 +179,6 @@ interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record - strict?: boolean } ``` @@ -216,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 @@ -308,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?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. 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 index 2e66dd9d9e..bbc55be966 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -12,7 +12,6 @@ Every operation resolves a user-supplied path to an opaque backend target first. ```ts type-equiv interface FsTarget { - inputPath: string targetKey: FsTargetKey displayPath: string } @@ -38,6 +37,18 @@ interface FsInfo { } ``` +`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. @@ -52,6 +63,8 @@ type FsWriteIntent = interface FsWriteOutcome { operation: 'create' | 'update' version: FsVersion + before: string | null + after: string } ``` @@ -67,9 +80,9 @@ interface FsEditRequest { ```ts type-equiv interface FsEditOutcome { - replacements: number - replaceAll: boolean version: FsVersion + before: string + after: string } ``` @@ -77,7 +90,7 @@ interface FsEditOutcome { `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-and-services.md](../cordis-catalog/events-and-services.md). +`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) @@ -93,16 +106,14 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv interface FileReadOutcome { offset: number - limit: number lines: FileTextLine[] totalLines: number truncatedByBytes?: true - version: FsVersion } ``` @@ -117,8 +128,11 @@ Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`Harn ```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' @@ -126,8 +140,8 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`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`. +`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`, `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 [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`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..32b5224773 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -85,7 +85,7 @@ interface SubagentProvider { } ``` -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 1c1744c45c..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`, `locations` — `{ path, line? }[]` files the call reads/modifies, for editor follow-along — and 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/user-interaction.md b/docs/core-data-structures/user-interaction.md index 0cf0864121..b0c5f5145c 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/core/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-ui-stdio` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/core/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-agent` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. Source: [`packages/core/user-interaction/src/index.ts`](../../packages/core/user-interaction/src/index.ts) 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..5460f662d4 --- /dev/null +++ b/docs/event-producer-consumer.md @@ -0,0 +1,36 @@ + + +# 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:234`](../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:241`](../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:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:319`](../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:332`](../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:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:345`](../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:274`](../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:250`](../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:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:368`](../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:77`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:70`](../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:26`](../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:32`](../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..9fb678d3d7 --- /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](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 e5377abafa..39d07fe876 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -3,157 +3,261 @@ # 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 - fs --> brand - fs --> llm - llm-deepseek --> llm - llm-pi-ai --> llm - session --> brand - session --> llm - system-prompt --> llm - agent --> brand - agent --> llm - agent --> session - compact --> llm - compact --> session - fs-local --> fs - fs-policy --> fs - llm-replay --> llm - llm-replay --> 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 - user-interaction --> agent - user-interaction --> llm - acp --> agent - acp --> llm - acp --> session - acp --> session-persistence - acp --> tools - acp --> user-interaction - 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-ask-user --> agent - tool-ask-user --> tools - tool-ask-user --> user-interaction - tool-bash --> agent - tool-bash --> bash - tool-bash --> llm - tool-bash --> tools - tool-fs --> fs - tool-fs --> llm - tool-fs --> system-prompt - tool-fs --> tools - tool-todo --> agent - tool-todo --> session - tool-todo --> tools - ui-stdio --> agent - ui-stdio --> session - ui-stdio --> user-interaction - 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 - acp-agent --> tool-ask-user - acp-agent --> user-interaction - stdio-agent --> agent - stdio-agent --> agent-core - stdio-agent --> session - stdio-agent --> session-persistence-jsonl - stdio-agent --> tool-ask-user - stdio-agent --> ui-stdio - stdio-agent --> user-interaction - 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"] + pkg_user_interaction["user-interaction"] + 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"] + pkg_tool_ask_user["tool-ask-user"] + 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_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_tools --> pkg_agent + pkg_tools --> pkg_llm + pkg_tools --> pkg_system_prompt + pkg_user_interaction --> pkg_agent + pkg_user_interaction --> pkg_llm + 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_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_acp --> pkg_user_interaction + pkg_tool_ask_user --> pkg_agent + pkg_tool_ask_user --> pkg_tools + pkg_tool_ask_user --> pkg_user_interaction + 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_acp_agent --> pkg_tool_ask_user + pkg_acp_agent --> pkg_user_interaction + 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 + pkg_stdio_agent --> pkg_tool_ask_user + pkg_stdio_agent --> pkg_user_interaction ``` -| Package | Depends on | -| --- | --- | -| `brand` | — | -| `bash` | `brand` | -| `llm` | `brand` | -| `bash-local` | `bash` | -| `fs` | `brand`, `llm` | -| `llm-deepseek` | `llm` | -| `llm-pi-ai` | `llm` | -| `session` | `brand`, `llm` | -| `system-prompt` | `llm` | -| `agent` | `brand`, `llm`, `session` | -| `compact` | `llm`, `session` | -| `fs-local` | `fs` | -| `fs-policy` | `fs` | -| `llm-replay` | `llm`, `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` | -| `user-interaction` | `agent`, `llm` | -| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools`, `user-interaction` | -| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | -| `subagent` | `agent`, `llm`, `tools` | -| `tool-ask-user` | `agent`, `tools`, `user-interaction` | -| `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | -| `tool-todo` | `agent`, `session`, `tools` | -| `ui-stdio` | `agent`, `session`, `user-interaction` | -| `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`, `tool-ask-user`, `user-interaction` | -| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `tool-ask-user`, `ui-stdio`, `user-interaction` | -| `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) | +| [`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) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | +| [`user-interaction`](../packages/core/user-interaction) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | +| [`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), [`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), [`user-interaction`](../packages/core/user-interaction) | +| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/core/user-interaction) | +| [`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), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/core/user-interaction) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/core/user-interaction) | 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/README.md b/docs/rfc/README.md index 5f7d7195bc..ef0e874c10 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. ## 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 the index tables below are **generated** from the tree (`pnpm run gen-rfc-index` rewrites the marker-delimited regions from each RFC's path, H1 title, and filename date; the gate fails when they are stale). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the tables are generated while this prose stays curated. | Class | What it covers | |---|---| @@ -37,20 +37,22 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r ## Proposed + ### Feature | Title | First proposed | |---|---| -| [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | +| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | +| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | ### Simplification | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [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 @@ -63,8 +65,8 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| -| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 | | [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 | +| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 | | [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 | | [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 | @@ -72,76 +74,107 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| -| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | | [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 | +| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | + ## Implemented + ### Feature | Title | First proposed | |---|---| | [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | -| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | | [Ask-user question capability](implemented/feature/2026-06-25-ask-user-question.md) | 2026-06-25 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | +| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | +| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | +| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | +| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | ### Simplification | Title | First proposed | |---|---| | [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | -| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | +| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | +| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | +| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [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 | |---|---| -| [Microkernel: extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | -| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | | [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 | | [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 | -| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | -| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | | [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 | +| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | +| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | +| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | | [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 | +| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | | [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 | | [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | -| [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | +| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | | [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | -| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | -| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | +| [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 | ### Process | Title | First proposed | |---|---| -| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 | +| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | | [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 | | [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 | -| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | +| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 | | [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | | [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 | | [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | +| [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 | ### Testing @@ -151,12 +184,16 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | -| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | | [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | | [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | +| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | +| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | +| [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 | @@ -172,6 +209,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 @@ -179,3 +217,4 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | | [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | + diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index 831b8d5325..c23e9069e8 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)); this file adds one rule specific to this folder. ## Keep an implemented RFC current with what actually shipped 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..bc6d71e6e9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,12 +10,12 @@ The harness needs one internal language for messages that the loop, session log, ## Decision -Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`, `image`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. +Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary. ## Consequences -- Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions. +- Reasoning 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-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index b9a182a4e9..212d2e82b7 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 @@ -17,7 +17,7 @@ Reject the pervasive `DeepReadonly` 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. 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..9dc91ae6da 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 @@ -6,13 +6,13 @@ Status: implemented (accepted 2026-06-11) ## 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. 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. ## 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`. @@ -20,7 +20,7 @@ The event vocabulary lives in interface packages (dsh-agent declares the agent/* ## 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-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md index e66440c3ee..46bfe88d50 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md @@ -20,7 +20,7 @@ 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. +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. 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. 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..63ebc1c875 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 @@ -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 index 731ee41ad7..50925b49cb 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -30,7 +30,7 @@ The read-before-write/edit and observed-state policy is a fourth package, `@deep 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`. +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. @@ -60,6 +60,7 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri The exact TypeScript signatures are implementation details for the PR, but the interface must cover four 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. @@ -92,7 +93,7 @@ Literal edit is a provider primitive (`editText`), not composed in `tool-fs` fro 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.) +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 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..b0d78bdc84 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -12,9 +12,9 @@ The deeper problem was a **coupled front-door cluster** that lived at the leaf w Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. 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`. @@ -37,7 +37,7 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## 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. 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..26ca40c647 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -0,0 +1,80 @@ +# 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. + +## Acceptance criteria (all landed) + +- `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-code`).** Considered for the `User-Agent` token, since the product's name is DeepSeek Code. `deepseek-harness` won on continuity: it is the identity providers already see from this codebase, it matches the org/repo and planned SDK-repo naming, and a public rename can change the product token deliberately later. + +## Risks / what we give up + +**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..6dc07221fa --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -0,0 +1,383 @@ +# 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: the first version should support at least Exa search and Perplexity search — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations), which 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 should 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. + +## Proposal + +Introduce web access as 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` should register model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: + +- Register `web_search` when web search is enabled for the product/app. +- Register `web_fetch` when web fetch is enabled for the product/app. +- Do not unregister a tool merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. +- Resolve the provider at execution time, and return a structured `WebError` 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 should stay close to `LlmService`: a `Map` 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 exact TypeScript signatures belong to the implementation PR, but the seam should expose this shape: + +```ts +interface WebSearchProvider { + readonly id: string + status(): WebProviderStatus + search(request: WebSearchRequest, exec?: WebExecContext): Promise +} + +interface WebFetchProvider { + readonly id: string + status(): WebProviderStatus + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +} + +interface WebService { + registerSearchProvider(provider: WebSearchProvider): () => void + registerFetchProvider(provider: WebFetchProvider): () => void + + search(request: WebSearchRequest, exec?: WebExecContext): Promise + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +} + +interface WebExecContext { + readonly signal?: AbortSignal +} +``` + +`WebExecContext` is execution control, not business input. The first version should carry only `signal` so `tool-web` can propagate turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It should not pass `ToolExecution` through the seam, because that would make `dsh-web` depend on `dsh-tools`. + +Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()` so the registration is torn down with the contributing fiber. + +## Provider status and selection + +Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. + +`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 should 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 such as environment variables may exist, but they must feed the same explicit selection path. For example, `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`; it is not a hidden priority chain inside `dsh-tool-web`. + +`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider. + +## Search request and result schema + +The first `web_search` model-facing tool should be small. The only model-facing argument is: + +- `query`: required string. + +`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — 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 should apply `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 should not include provider-specific controls such as Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth in the first version. Those fields should be added only when they have 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` should not be required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` can render `title ?? hostname(url)` 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 should map 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 should map `choices[0].message.content` to `content` and prefer 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 first `web_fetch` implementation should be an anonymous public HTTP(S) fetch provider, likely `local-http`. It should fetch bytes from a concrete URL, apply the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decode textual content, and return only the minimal model-useful result: final URL, status code, body, and truncation. It should not carry 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 first seam request should stay 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, add a separate `web_extract` capability or explicitly widen this RFC before implementation. Do not smuggle extract semantics 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 should return `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 the first version should not add separate `requestedUrl` and `finalUrl` fields. + +`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 must define resource controls before the tool ships: + +- Accept only `http:` and `https:` URLs. +- Reject credentials in URLs. +- Enforce maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap. +- Propagate abort signals through network fetches and expensive decoding. +- Automatically follow only same-origin redirects. +- Fail cross-origin redirects 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.) +- Use an explicit product user agent rather than silently impersonating a browser by default. + +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 in the first version is a minimal stable sync: + +1. On plugin startup, read the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) that enables or disables each web tool. +2. If web search is enabled, register `web_search` (its disposer is fiber-scoped via the effect-based registry). +3. If web fetch is enabled, register `web_fetch` (likewise fiber-scoped). +4. Do not dispose either tool merely because its selected provider is missing, unusable, or ambiguous. +5. Disposing the `tool-web` fiber tears down its registrations automatically. + +Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. + +Prompt guidance should explain the semantic split: use `web_search` for discovery and current information, then use `web_fetch` when the model needs the content of a specific URL. The prompt and tool result should tell the model to cite relevant URLs with markdown links. + +The model-facing output should be text-first because current tool results are `ContentBlock[]`, but the seam outcome should stay structured so UI presentation and future adapters do not have to scrape rendered text. + +## Errors + +`dsh-web` should define `WebError extends HarnessError` with stable codes. Initial codes should include 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); the first version does not split out a separate `WEB_NETWORK` code, but the provider should set a descriptive message so the model and logs can tell a network failure from a provider API failure. + +Tool execution should let 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. + +## Tests + +Tests should prove the seam contract without turning this RFC into an implementation checklist. + +`dsh-web` tests cover provider registration and disposal (proved through execution behavior — a registered provider serves `search()`/`fetch()`, a disposed one no longer resolves), duplicate provider ids, the selection table above exercised through execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes. + +Search provider tests cover request mapping, response parsing into `content` plus `sources[]`, missing credentials, provider errors, timeout/abort, truncation, and a self-skipping with-key smoke test for each real provider. Perplexity fixtures must include URL-only citations so the optional source fields stay honest. + +`dsh-web-fetch-local` tests cover real HTTP behavior using a local test server: valid text and HTML fetches, non-2xx HTTP responses returned as results, byte/decoded-body caps, timeout, abort, invalid URLs, credential-in-URL rejection, cross-origin redirect blocking, unsupported content types, and product user agent. (Private-destination/SSRF blocking tests come with that deferred work.) + +`dsh-tool-web` tests execute through the real tool registry. They verify schema registration follows product/app tool enablement rather than provider availability, unavailable or ambiguous providers produce structured execution errors, argument validation, formatting of successful search/fetch results, structured error propagation, and cleanup on disposal. + +Integration tests should load the real seam, provider, and tool packages together and execute through `ctx.tools.execute()` rather than calling providers directly. If wiring the tools into an ACP-facing example changes editor-visible transcripts, add or update the relevant snapshot scenario in the same change. + +At least one test must drive these packages through their REAL cordis Loader/export path, not a hand-built `ctx.plugin({...})` mount, so a broken export shape is caught (see [docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md) and `packages/AGENTS.md` § plugin-export-shape). The two shapes need different guards: `dsh-web` is a **service** (`export default` the class) and a stray extra export would surface as a missing service; the provider packages and `dsh-tool-web` are **namespace plugins** (named `name`/`inject`/`apply`, NO default), and because each has `inject`, a stray `export default apply` makes `unwrapExports` drop the `inject` and the plugin throws `cannot get property … without inject` the moment it loads — so a Loader smoke that boots tool-web over `ctx.web` catches it (and each provider's registration test mounts it the real way and asserts no default export). Prove the guard bites: add `export default apply` to `tool-web`, watch the smoke go red, revert. + +## Migration plan + +This is new capability work, so no compatibility migration is required while the harness is unreleased. + +Land the work in seam order: + +1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, selection, request/result/error types, and contract tests. +2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test. +3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test. +4. Add `packages/web/web-search-deepseek` with parser/unit tests and a self-skipping real-provider smoke test. +5. Add `packages/web/web-fetch-local` with local HTTP behavior tests. +6. Add `packages/web/tool-web` with config-driven tool registration, prompt sections, model formatting, presentation, and tool-registry tests. +7. Wire product app/example configs only after package behavior is stable, because tool schemas and prompt sections affect agent behavior and snapshots. +8. Update `docs/architecture.md`, `packages/README.md`, package READMEs, generated Cordis catalogs if new events/services are added, and maintenance scripts. + +## 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`. + +## Risks + +**The search schema may be too thin.** Exa and Perplexity both expose useful provider-specific controls. The first version should resist adding them until they can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. + +**Perplexity citations may be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` must render useful fallback labels. + +**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. + +**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path must resolve again and fail with a structured error. + +**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can still reach sensitive network targets or exfiltrate data through URLs. The first version ships only the basic transport hygiene (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 must enforce byte/character caps and report `truncated`; `tool-web` must format 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-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..0d797fa8a2 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -0,0 +1,33 @@ +# RFC: stdin + extra env on the bash seam + +Status: implemented (accepted 2026-06-30) + + + +## Context + +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` 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`. + +## Scope: configurable scrub pattern is NOT included + +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..eff6164dbf --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -0,0 +1,35 @@ +# RFC: Event-domain semantics — session is the fact log, agent is the live surface + +Status: implemented (accepted 2026-06-30) + +## Context + +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. 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..9c95dff773 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -0,0 +1,55 @@ +# 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. + +### The diff algorithm — a third-party runtime dependency over vendoring + +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`). + +## 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..d574f50bba --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -0,0 +1,70 @@ +# 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. + +## 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..a02cfeb5bb --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -0,0 +1,53 @@ +# Add direct directory listing to the filesystem seam + +## Status + +Implemented. + +## Context + +`@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 `/SKILL.md` or `.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. + +## Rejected alternatives + +**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/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 9e08df2fbd..f09dab5cd1 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (chars per token — the `charsPerToken` config, default 4 — + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation diff --git a/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..77a838b299 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 @@ -66,7 +66,7 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin ## Risks and deferrals -- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. +- **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..aa56483f32 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 @@ -36,7 +36,7 @@ The child is a separate process, so it inherits an environment. Credential-shape ## 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. diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index bc5c140fa5..7788a4a9f9 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -20,7 +20,7 @@ Optionless questions are always free-form, even if a caller passes `allowCustom: ## UI mappings -`dsh-ui-stdio` renders the question in readline, sorts recommended options first, shows each option's `description` on the next line, accepts the recommended option on an empty answer, and rejects pending questions on abort, provider disposal, or stdin EOF. The stdio provider serializes multiple simultaneous questions with an internal queue so only one prompt owns stdin at a time. +`dsh-stdio-agent`'s in-package readline module renders the question, sorts recommended options first, shows each option's `description` on the next line, accepts the recommended option on an empty answer, and rejects pending questions on abort, provider disposal, or stdin EOF. The stdio provider serializes multiple simultaneous questions with an internal queue so only one prompt owns stdin at a time. `dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form. Option choices become a `choice` single-select field with the recommended option as the schema default; free-form answers use `answer` for optionless questions and `custom_answer` when options plus custom input are allowed. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. @@ -36,4 +36,4 @@ The feature gives the model a powerful pause primitive, so prompt guidance matte ## Test plan -Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, structured tool errors through `ctx.tools.execute()`, option labels/values, and the model schema including the removal of `desc`. `dsh-ui-stdio` tests cover recommended-first display, descriptions, queued questions, EOF/abort cleanup, and optionless free-form input even with `allowCustom: false`. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify both selected-option and optionless free-form elicitation paths continue the agent loop. +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, structured tool errors through `ctx.tools.execute()`, option labels/values, and the model schema including the removal of `desc`. `dsh-stdio-agent` tests cover recommended-first display, descriptions, queued questions, EOF/abort cleanup, and optionless free-form input even with `allowCustom: false`. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify both selected-option and optionless free-form elicitation paths continue the agent loop. 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..3bedd73b34 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -0,0 +1,69 @@ +# RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges + +Status: implemented (accepted 2026-06-30) + + + +## Context + +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. + +### Multiple hooks on one point run serially, not concurrently + +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..12a47ddcf1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -0,0 +1,32 @@ +# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core + +Status: implemented (accepted 2026-06-30) + + + +## Context + +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`). + +### Why "shared core + per-dialect adapters", not "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..138fe975a1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -0,0 +1,45 @@ +# RFC: Interception seams — the typed-Decision surface a hook programs against + +Status: implemented (accepted 2026-06-30) + + + +## Context + +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). + +## 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..b67ede3ab6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -0,0 +1,32 @@ +# RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only) + +Status: implemented (accepted 2026-06-30) + + + + +## Context + +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. + +## 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..ba44b61373 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 @@ -13,11 +13,11 @@ 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. -**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. ## Consequences 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..d578d8091c 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 @@ -23,7 +23,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`. 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..a53b1f40f6 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 @@ -14,7 +14,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.` 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.` 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). diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md index d3fc95399b..21d4129fac 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -29,18 +29,18 @@ The `architecture` / `process` line: **architecture** is about the source we shi Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don't-generate, exit non-zero on the first violation): -- **`scripts/verify-rfc-classification.ts`** — the closed set and index completeness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that `README.md` lists every RFC exactly once under the `###` heading matching its `{lifecycle}/{class}` path. The canonical class set lives as a `const` in this script — the machine source of truth — and [the index](../../README.md) documents it in prose; the two are kept in sync by hand (the README's completeness is gated, its class *descriptions* are not). This mirrors `verify-event-taxonomy`, which checks a doc table against source. +- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the README's marker-delimited index regions byte-match a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the index](../../README.md) documents it in prose; the README's class *descriptions* stay hand-written, its tables are generated. - **`scripts/verify-doc-refs.ts`** — source comments that cite docs. RFC paths are referenced not only from Markdown but from TypeScript doc comments (root-relative prose like `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`). `verify-md-links` never saw those, so the reorg could have silently orphaned them. This gate scans repo-authored `.ts` under `packages/**` and `examples/**` (excluding built `lib/` and `vendor/`) for `docs/….md` tokens, resolves each root-relative, and asserts it exists. It requires the `.md` extension so extensionless prose (`docs/postmortem/0001`, `docs/architecture.md § plugin checklist`) is left alone. ### Rejected alternatives - **A `Classification:` prose line** in each file (next to `Status:`), parsed by the gate. Workable, but it duplicates into the file a fact the path can already carry, and a line can disagree with its folder. Path-encoding makes the label and its storage the same thing — there is nothing to keep in sync. - **A `refactor` class.** It overlaps `simplification` almost entirely; the only discriminator anyone reached for was "does observable behavior change?", which `simplification` already encodes (it does not). One class, not two. -- **Auto-generating the README index** from the filesystem. Rejected to keep the index hand-written like every other doc here; the completeness gate gives the same drift-protection without generated Markdown in a curated file. +- **Auto-generating the README index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the tables are now generated between markers while the surrounding prose stays curated. ## Consequences - Every RFC now sits under a class folder, and the index groups by class within each lifecycle. A reader scans one heading to see all simplifications, or all testing decisions. - Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`). -- Adding a class is a deliberate act: amend the `const` in `verify-rfc-classification.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. +- Adding a class is a deliberate act: amend the `const` in `scripts/rfc-index.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. - Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see. diff --git a/docs/rfc/implemented/process/2026-07-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..bc9a1cd466 --- /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: 517a6371eca5d747313c7efdb2756a50257701e4 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f8f68bf5d4d7e6795318d9dd435a525f20a4f407 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..517a6371ec --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -0,0 +1,36 @@ +# Bilingual documentation via paired sibling files and a pairing gate + +English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md) + +## Context + +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 ` 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..f8f68bf5d4 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -0,0 +1,36 @@ +# 通过配对兄弟文件与配对门禁实现双语文档 + +[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 ` 能还原任一侧上次确认的文本,用于基于 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..479d93c2df 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 @@ -4,7 +4,7 @@ Status: implemented (accepted 2026-07-02) ## Context -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" 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..c8d46c6fd2 --- /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 (accepted 2026-07-03) + +## Context + +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 pressure is already visible in the open stacks even though this implementation is based on `origin/master`: the hooks stack through PR #129 makes event producer/consumer topology and interception points much more important, while the filesystem stack through PR #128 makes capability seams, policy vetoes, tool presentation, and SDK assembly paths much more important. Graphs based only on today's small bash/todo/subagent surface would become obsolete as soon as those stacks land. + +## 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](../../../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//` 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.` 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. + +## Format choices + +Use Mermaid for committed diagrams because GitHub renders it in Markdown and it adds no new docs build dependency. Use Markdown tables for dense many-to-many data such as event producer/consumer relationships. Do not adopt PlantUML, hosted diagram services, or generated SVGs 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..c86fe5533f --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -0,0 +1,33 @@ +# RFC: JSDoc completeness gate for the cordis surface + +Status: implemented (accepted 2026-07-04) + +## Context + +The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.` 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` (where `@returns` stays optional — resolution timing can be worth documenting — but is never required). +- **Stale tags error**: an `@param` naming no real parameter is a violation, mirroring the `@mode`-contradicts-signature check. Tag descriptions must be non-empty; their semantic quality beyond that is review's job. +- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). +- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. + +The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. Rendering them — restructuring the services section into per-method entries — was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. No escape-hatch tag exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. + +Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. + +## Consequences + +- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. +- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. +- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. +- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. +- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. diff --git a/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..c33c917112 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -0,0 +1,33 @@ +# Documentation tiers, budgets, and the ceiling gate + +## Context + +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`. +- [The web capability seam RFC](../architecture/2026-06-24-web-capability-seam.md) converted from spec-speak to shipped reality (drop the migration plan and test enumeration, "should" → "is"). +- `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..8039dd50f4 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md @@ -0,0 +1,26 @@ +# RFC: Generate the RFC index tables + +Status: implemented + +## Problem + +`docs/rfc/README.md`'s per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do. + +## Decision + +Keep the curated prose; generate the tables. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it: + +- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites the three marker-delimited regions in the README (`` … `end`), one per `## {Lifecycle}` section, leaving everything outside the markers untouched. +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure and asserts the committed regions byte-match a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed. + +Adding, moving, or deleting an RFC means editing only the RFC file and running the generator; the classification RFC's rejected-alternatives record carries the supersession cross-link. + +## Why not the verifier-only model? + +It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts. + +## Consequences + +- The generated regions are explicit: marker comments make script ownership obvious to reviewers, and the generator refuses to run on a structurally invalid tree. +- A malformed or missing H1 is a hard error in both the generator and the gate — the H1 is now load-bearing as the index title source. +- Concurrent RFC branches resolve index conflicts by rerunning the generator, never by hand-merging rows. diff --git a/docs/rfc/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..b03c79e84a --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -0,0 +1,29 @@ +# RFC: Generated persistence log event catalog + +Status: implemented (accepted 2026-07-04) + +## Context + +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` and unlike the boot-based tool catalog — the right technique because 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. + +## 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/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index 67fe9b07fc..60ad056f18 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 @@ -2,7 +2,7 @@ Status: implemented (proposed 2026-06-20; accepted in amended form — `whenIdle()` retained) -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [AGENTS.md § Defensive patterns](../../../../AGENTS.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped. +> **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 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..f198ff9ec6 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -0,0 +1,40 @@ +# RFC: Stop mirroring durable boundaries as agent events + +Status: implemented (accepted 2026-07-01) + + + +## 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 `[ 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. + +## 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: 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 index 736b65df08..48a1e5e47c 100644 --- 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 @@ -115,6 +115,10 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import - Docs and generated artifacts are updated: `docs/architecture.md`, `packages/README.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, affected `type-equiv` blocks and `scripts/type-equiv.manifest.json`, Cordis catalog, module graph, and doc references. - Gates stay green: normal `doc-sync`, `pnpm run knip`, and `pnpm run test:coverage` with 100% per-file 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. + ## Risks - Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam. 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..73c62a6864 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -0,0 +1,41 @@ +# RFC: Stop mirroring the token stream as an agent event + +Status: implemented (accepted 2026-07-02) + +## 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. + +## What we give up + +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..ccbf5d755d --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -0,0 +1,27 @@ +# RFC: Drop the `image` content block until a path can honor it + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +`ImageBlock` (`packages/llm/llm/src/types.ts`) had no production producer, and every consumer on every path DROPPED it: the deepseek adapter's serializer skipped image blocks (a documented MVP limitation), the pi-ai converter skipped them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwarded image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charged a flat token constant and rendered `[image]`. An `ImageBlock` constructed then would silently vanish from the wire — the vocabulary advertised a capability no path honored, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere were tests pinning the skip/drop/estimate branches. + +## Decision + +Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. + +## Why not keep it? + +This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. + +The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. + +## Acceptance criteria + +- No `ImageBlock` / harness `type: 'image'` block construction outside this RFC; the codec's inbound ACP-image rejection still passes its tests. +- Adapter/codec/compaction switches handle the case through their unknown-block default arms (pinned by the plugin-added-block tests). +- Doc pastes, the manifest, and the architecture vocabulary list updated; `pnpm run doc-sync` green. + +## Risks + +Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md new file mode 100644 index 0000000000..3724d680f5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -0,0 +1,33 @@ +# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +Two request-contract knobs rode the whole request pipeline, yet neither could do anything: + +- **`prefill`** (`packages/llm/llm/src/types.ts`) had no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters rejected it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each threw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior was two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. +- **`strict`** (`ToolSchema`, same file) was threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note recorded that strict mode requires the `/beta` base URL the adapter does not use), a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`, and a conditional `Strict:` row in the tool-catalog renderer (`scripts/gen-tool-catalog.ts`). No shipped tool set it — `rg` across every `tool-*` package src and `examples/` found zero `strict:` producers; the only setters were dsh-tools unit tests. + +Both knobs were adapter-symmetric, so removal shed them from both twins together — the [twin-adapter design](../architecture/2026-06-13-twin-llm-adapters.md) is untouched. + +## Decision + +- `prefill` is removed from `GenerateOptions`, along with both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste line in [core.md](../../../core-data-structures/core.md), and the adapter README rows documenting the rejection. The cookbook's UNSUPPORTED guidance ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)) states the rule generically — a `GenerateOptions` field your provider cannot honor throws `LlmError(..., 'UNSUPPORTED')` — instead of using prefill as the example. The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record prefill as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). +- `strict` is removed from `ToolSchema`, `DefineToolOptions`, `defineTool`, the `schemas()` allowlist, the deepseek serializer branch and its wire-type field, and the tool-catalog renderer's `Strict:` row. The pi-ai payload fixup is simplified to the unconditional scrub of pi-ai's own per-tool strict default (pi-ai stamps `strict: false` on every serialized tool; the hand-rolled twin sends no such field, so the scrub survives for wire parity, pinned by its serializer test). The setter tests and the core.md paste line are gone; both `GenerateOptions` and `ToolSchema` keep their rows in `scripts/type-equiv.manifest.json`, since each type survives minus a field. + +This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. + +## Why not keep them? + +"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. + +## Acceptance criteria + +- `rg prefill` returns only RFC records (this one and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s producer-gated consequence); a tool-schema-scoped `rg strict` returns only this RFC, the surviving pi-ai scrub, and unrelated prose such as `strictEqual`. +- Both adapters compile and their contract tests pass without the guards; the pi-ai fixup still scrubs the library's strict default (wire parity pinned by its serializer tests). +- Doc pastes and the type-equiv manifest in sync; `pnpm run doc-sync` green. + +## Risks + +The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) would reach for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/docs/rfc/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..7c5881b106 --- /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 (proposed and accepted 2026-07-04) + +## Problem + +`WebService` exposes an observation surface no production code observes: + +- **`web/providers-change`** (`packages/web/web/src/index.ts`) is declared and emitted on every provider registration and disposal, and each registration effect's rollback yield is ordered BEFORE the emit solely so a throwing change listener unwinds the registration. No listener exists outside the package's own two unit tests (one of which exists to pin that rollback ordering). +- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) 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. + +## Proposal + +Delete the event declaration, both emits, and the rollback-before-emit ordering (the plain `ctx.effect` disposer keeps HMR cleanup). Delete `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` — the provider-private `status()` stays, since it feeds execution-time selection. Delete the listener-throw rollback test that exists solely for the removed event, and rewrite the emission assertions and every status-based assertion onto the behavior a real caller observes (a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets). Run `pnpm run gen-cordis-catalog`; update `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md). Amend the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) per [implemented/AGENTS.md](../AGENTS.md). + +## Why not keep it? + +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. + +## Acceptance criteria + +- No `providers-change`, `searchStatus`, `fetchStatus`, or `WebCapabilityStatus` spelling outside RFC history; the catalog is regenerated and fresh (`verify-cordis-catalog` green). +- Registration/disposal HMR-safety tests prove cleanup through execution behavior rather than the removed surfaces. +- `packages/web/tool-web/README.md` and the architecture paragraph describe the execution-time error-routing contract the tool actually has. + +## Risks + +A future provider-picker UI or diagnostics panel wants change notifications or a status query — it 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..599aacefac --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -0,0 +1,24 @@ +# RFC: Fold the stdio UI helper into the stdio app + +Status: implemented + +## Problem + +The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-agent`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. + +The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. + +## Decision + +The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep 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. + +## 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..d938f3eafb --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -0,0 +1,31 @@ +# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: + +- **`CacheHint` and its `cache?: CacheHint` block fields** on `TextBlock`/`ToolResultBlock` (`packages/llm/llm/src/types.ts`; the image block carried a third such field, which left with it — see [the drop-image RFC](2026-07-04-drop-image-content-block.md)). Nothing constructed a block with `cache:` anywhere — src, tests, and doc pastes all came up empty — and neither adapter read `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This was Anthropic-style `cache_control` surface with no provider that could honor it. +- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. +- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer was one hand-built test fixture needing an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`), which an `injection` trigger serves equally; the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. + +## Decision + +`CacheHint`, 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. + +## Why not keep them? + +The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) listed "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. + +## Acceptance criteria + +- `rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only RFC records (this one, and [the drop-image RFC](2026-07-04-drop-image-content-block.md)'s account of the image block's own `cache` field). +- The core-data-structures pastes and the type-equiv manifest are in sync (`pnpm run doc-sync` green). +- The fixture asserts the same replay behavior with an `injection` trigger; the suite is green. + +## Risks + +None operational — nothing could construct these values. The mirror-event removals (recorded in [the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. The image block'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..b80c22af4e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -0,0 +1,29 @@ +# RFC: Prune write-only fields and a dead routing knob from the fs seam + +Status: implemented (proposed and accepted 2026-07-04) + +## 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. + +## 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. + +## Acceptance criteria + +- The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditRequest`) and the version fields on the other outcome types are untouched; doc pastes and the manifest in sync; the suite is green with the shrunk fakes. +- `formatEditOutput`'s emitted text is unchanged for both `replace_all` branches, so no snapshot golden churns. + +## Risks + +The in-flight fs discovery work (glob/grep tools) touches the same `dsh-fs` type files — a textual, not design, conflict; land in either order and reconcile mechanically. Backends gain no new obligations; they shed four. diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md new file mode 100644 index 0000000000..a8a5edd3a2 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -0,0 +1,30 @@ +# RFC: Remove the `agent/steering` mirror emit + +Status: implemented (accepted 2026-07-04) + +## Problem + +`agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. + +Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC makes. The [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) kept it as a live control signal rather than a boundary; the [stream-chunk removal](2026-07-02-remove-stream-chunk-mirror.md) retained it on the reading that it had no durable twin. The second rationale did not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fired at the exact moment its durable twin landed, carrying nothing the log does not. + +Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observed the mirror. + +## Decision + +`agent/steering` is removed from the agent event taxonomy: the declaration in `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainSteering` (whose then-unused `ctx` parameter went with it), the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (the `packages/core/agent-loop/src/loop.ts` module doc and [architecture.md](../../../architecture.md)); the cordis catalog is regenerated without it. The one regression test pins source preservation on the durable `steering/message` event — the fact it pins lives on the log. + +Three implemented RFCs stated the retention, and each is amended per [implemented/AGENTS.md](../AGENTS.md) to point here as the record of the removal: the [boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md)'s retained-list entry, the [stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)'s scope clause, and the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s transient-emit enumeration. + +## Why not keep it? + +"It is a control signal, not a boundary" — but the taxonomy's operative distinction is mirrored-vs-live-only, not control-vs-boundary, and this event mirrored. A consumer that wants enqueue-time notification has `agent/queued` (with its steering flag); a consumer that wants drain-time notification is by definition asking for the moment `steering/message` is appended, which `session/event` delivers with the same payload plus durability. The rejected [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) defended the steering *capability* — `steer()`, the durable event, continuation forcing — all of which this removal keeps untouched. + +## Acceptance criteria + +- The `agent/steering` spelling survives only in RFC prose (this RFC, the three amended RFCs above, and the frozen [rejected steering-capability RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md), whose text records the proposal it declined); the catalog is regenerated and fresh. +- The retargeted test pins source preservation on `steering/message`; the suite is green. + +## Risks + +None known: zero production listeners existed to migrate, and both live-notification needs (enqueue, drain) have surviving homes (`agent/queued`, `session/event`). diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md new file mode 100644 index 0000000000..faa7b3f106 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -0,0 +1,23 @@ +# RFC: Share the app bins' boot glue instead of maintaining twin copies + +Status: implemented + +## Problem + +`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes. + +## Decision + +The helpers live once, in [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) (`packages/ui/app-boot`, in the `ui` group because the bins are published artifacts whose runtime dependency must itself be published, not `support/`): `resolveConfigPath` (snapshot-aware, the single path resolver for both bins), `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, and `boot`, each parameterized by the bin's diagnostic prefix and injectable at its side-effect seams (the warn sink, the process slice) so the unit suite covers every branch — including `boot()` driven in-process against the real Loader with relative-specifier configs, both the settled-tree happy path and the fiber-less-entry rejection. The package carries the per-file 100% coverage gate; the loader-failure lore has one home. + +Each `bin.ts` is a thin self-executing composition over the shared helpers plus its app-specific lifecycle (the ACP bin: replay-mode env skipping and the stdin-EOF dispose; the stdio bin: nothing extra). The bins stay coverage-excluded and export nothing; the published-artifact guards are unchanged — the built-bin smokes still run each bin under plain node in a node_modules-shaped temp dir (now symlinking `ui/app-boot` too) and still assert the missing-config non-zero exit, per the "real entry path means the published artifact" defensive pattern. The [extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md)'s bin-ownership facts are amended accordingly. + +## Why not keep the duplication? + +The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) comparable to the deduplicated line count. But app-vs-app sharing was never weighed by the RFC that created the bins — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift was observed fact; and the coverage-gap argument is independent of the dedup argument: this was the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The recorded fallback (extracting only the pure logic into per-app modules) would have ended the exemption but kept two homes for the lore. + +## Consequences + +- A boot-glue change (a new guard, a resolution fix) lands once and both published bins inherit it; the bins cannot drift apart again. +- `dsh-app-boot` stays dependency-light (cordis + the loader/include pair) — it is boot machinery, not app surface. +- The bins' own files are near-trivial compositions; everything with branches lives under the coverage gate. diff --git a/docs/rfc/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..9ee422b130 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -0,0 +1,31 @@ +# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +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. + +## What shipped + +`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`). + +## Why not keep them? + +The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. + +## Acceptance criteria + +- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns nothing. +- `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer; `durationMs` stays on `hook/result` (and in the fixtures), with the normalizer's replay scrub intact. +- Both bridge configs keep `defaultTimeoutMs`/`stderrSummaryMaxChars` (the audit's explicit-tunables shape), but the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`; per-hook `timeoutSec` still overrides the timeout. +- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. + +## Risks + +The `dialect`, `suppressOutput`, 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..3df0e0322a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -0,0 +1,22 @@ +# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback + +Status: implemented (accepted 2026-07-04) + +## Problem + +Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: + +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". + +## Decision + +`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for. + +## Why not keep them? + +`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call. + +## Risks + +None beyond the fallback rendering trade described above — degenerate paths whose neutral card is more diagnosable than an inferred first-party one. diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 7f6cd47b48..cd7058487f 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -46,7 +46,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. The rest of the tree is not duplicated: both the normal `examples/acp-agent/cordis.yml` and the replay config load the same `@deepseek-ai/dsh-acp-agent` app entry (which bundles the agent-core spine + JSONL persistence + the ACP bridge), differing only in the LLM backend (`llm-deepseek` vs `llm-replay`) and the bash executor line. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. +The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot boot the normal config as-is — `examples/acp-agent/cordis.snapshot.yml` is an include-overlay of `cordis.yml` that disables the `llm-deepseek` entry by id and inserts `llm-replay` (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)); every other entry IS the live tree, loaded through the include. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. ### Two surfaces: normalize, then compare diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 2001147be8..42b2e55ffd 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 @@ -6,7 +6,7 @@ Status: implemented (accepted 2026-06-19) ## Context -The harness leans hard on real-API tests by policy: AGENTS.md § Secrets argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. +The harness leans hard on real-API tests by policy: [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 +22,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 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..ad61bd3b84 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -0,0 +1,46 @@ +# 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---`: + +- **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. 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..c5f836b9f7 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -0,0 +1,23 @@ +# RFC: Single-source the acp-agent replay config + +Status: implemented + +## Problem + +`examples/acp-agent` shipped two hand-maintained configs: `cordis.yml` (the live tree) and a `cordis.snapshot.yml` that mirrored it entry-for-entry with only the llm backend swapped — stripped of comments, the entire difference was the eight-line `llm-deepseek` stanza versus the two-line `llm-replay` stanza. Every app-shape change had to be made twice, and nothing gated the symmetry: if the copies drifted, the snapshot tier would silently exercise a different app than the one that ships — the ["green units, broken product" class of gap](../../../postmortem/0001-acp-default-export-drops-inject.md) the snapshot tier exists to close, reintroduced one level up, with reviewer vigilance as the only defense. + +## Decision + +`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry (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. + +## 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-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index 4f034e3020..7f7c5ed007 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. 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..36396e233d 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 @@ -7,7 +7,7 @@ Status: proposed ## 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 +15,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 +27,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 +46,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` 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. 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..f5da38c14c 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 @@ -55,7 +55,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: , 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: , 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: }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise` 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. 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..a3bb6d4718 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -0,0 +1,39 @@ +# RFC: Pre-tool input rewrite — a consistent design + +Status: proposed (2026-06-30) + + + +## Context + +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.) + +## Proposed design (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()`. + +## 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. + +## 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-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 784a36c593..a85e196ffd 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..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//` 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,12 +16,16 @@ 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 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..c7978183ef 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-`). - **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 === `. +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 === `; 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. @@ -40,7 +41,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..85121f3293 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -0,0 +1,30 @@ +# 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. + +## 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/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..8f971bb15a 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,7 +20,7 @@ This proposal can land independently of [a generic long-running tool runtime](.. - `OutputCollector` keeps bounded buffers only and deletes the temp-file machinery. - `renderResult()` reports truncation without a filesystem path. - Tests cover tail truncation and no longer assert full-output file contents. -- Security guidance in [root AGENTS.md](../../../../AGENTS.md) stops treating private spill files as a model-visible interface. +- Security guidance in [docs/defensive-patterns.md](../../../defensive-patterns.md) stops treating private spill files as a model-visible interface. ## What we give up 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..9eaa1a5a8d --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -0,0 +1,35 @@ +# 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. + +## 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 9e04094703..3ec06fd1bb 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -3,12 +3,25 @@ # 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-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | +| `@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-ask-user` ### `ask_user_question` @@ -68,6 +81,8 @@ Ask the user a concise question when you need confirmation, a choice, or missing Source: [`packages/ui/tool-ask-user/src/index.ts`](../../packages/ui/tool-ask-user/src/index.ts) +ask_user_question pauses the tool call until the active UI provider returns a human answer. + ## `@deepseek-ai/dsh-tool-bash` ### `bash` @@ -150,6 +165,8 @@ 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` @@ -318,3 +335,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 @@ + + +# 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: tool/call
logged before execution"] + presentCall["UI pending card
presentCall(args)"] + pre["tools/pre-execute waterfall
hooks, permission, sandbox"] + denied["deny or ask
tool body skipped"] + toolBody["Registered tool execute() body"] + fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] + owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result"] + post["tools/post-execute waterfall
accept, block, replace, add context"] + context["Buffered additionalContext
context/message after all tool results"] + toolResult["Session event: tool/result
single model-facing outcome"] + presentResult["UI completed card
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 a95814bbbd..1e3134ba2d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,12 +15,12 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -The real thing: 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. 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 e9a38f2313..278ea74e53 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -1,6 +1,6 @@ # 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 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) 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 @@ + + +# 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
cordis.yml"] + plugin_acp_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_acp_llm_deepseek + plugin_acp_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_acp_bash + plugin_acp_acp_agent["acp-agent
@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
JSON-RPC stdio bridge
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
@deepseek-ai/dsh-subagent"] + cfg --> plugin_acp_subagent + plugin_acp_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_acp_subagent_spawn + plugin_acp_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_acp_subagent_fork + plugin_acp_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_acp_tool_subagent + plugin_acp_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_acp_tool_subagent_fork + plugin_acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_acp_tool_todo + plugin_acp_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_acp_fs_local + plugin_acp_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_acp_fs_policy + plugin_acp_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_acp_tool_fs + plugin_acp_hooks_claude["hooks-claude
@deepseek-ai/dsh-hooks-claude"] + cfg --> plugin_acp_hooks_claude + plugin_acp_hooks_codex["hooks-codex
@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 8a6f17d012..fd8a9dcad2 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,101 +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; filesystem, subagent, and todo_write are loaded below. -- id: bash - name: '@deepseek-ai/dsh-bash-local' +# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. The replay +# fixture path comes from $DSH_SNAPSHOT_FILE (and an optional +# $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. stdout stays +# reserved for the ACP JSON-RPC protocol (the app package loads no stdout +# logger). Patches apply when the include loads the file — a one-shot replay +# boot, so the load-time-only patch semantics are exactly enough. +- id: base + name: '@cordisjs/plugin-include' config: - timeoutMs: 60000 - -# The ACP server app — identical to cordis.yml's entry. -- id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' - config: - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. - - Your tools are read/write/edit for file operations, bash (plus - bash_output/bash_kill for background tasks), and subagent. Use read to - inspect UTF-8 text files, write to create or replace files, and edit for - targeted literal replacements. Use bash for shell commands, tests, - searches, and operations that are not ordinary file reads or edits. Each - bash call runs in a fresh shell — pass workdir instead of cd. Check the - [exit code: N] marker; verify your work. Keep answers brief and factual. - - Use the subagent tool to delegate a focused, self-contained subtask to - a fresh child agent (it works in its own context and returns only its - final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. - -# The subagent seam + both in-process backends + two model-facing tools — -# identical to cordis.yml's wiring (only the LLM backend differs above): spawn -# and fork are each reachable via a dsh-tool-subagent bound to it with a distinct -# toolName (subagent → spawn, subagent_fork → fork). -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -# The model-facing todo_write tool — identical to cordis.yml's wiring, so a -# replayed todo_write tool call resolves to a real tool during snapshot replay. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Filesystem capability stack — identical to cordis.yml's wiring, so replayed -# read/write/edit tool calls resolve to the real tools during snapshot replay. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' + 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 35c3176f34..01849bb66e 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -112,3 +112,30 @@ - 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 7d19f44bb5..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,6 +62,7 @@ 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 }, @@ -59,6 +70,7 @@ const SCENARIOS: Scenario[] = [ { 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 }, @@ -66,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` …). */ @@ -144,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, + Readable.toWeb(child.stdout) as ReadableStream, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + updates.push(params.update) + return Promise.resolve() + }, + requestPermission(_params: RequestPermissionRequest): Promise { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + const client = new ClientSideConnection(makeClient, stream) + return { child, client, updates, stderr } +} + +let spawned: Spawned | undefined +let 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 + if ('durationMs' in data) data.durationMs = 0 + } } return scrubValue(record, ctx) as Record }) diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 0e6b253480..d491e4cf0f 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,147 +1,131 @@ -{"type":"session","version":0,"id":"2d43b6e7-859c-4e20-9145-3bcfe4c29836","createdAt":1782993777165,"cwd":"/tmp/acp-snap-cwd-yl8qhJ"} -{"type":"turn/start","seq":0,"time":1782993777170,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1782993777170,"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":1782993777171,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":5,"time":1782993777707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":6,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":7,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":8,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":9,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":10,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" config"}}} -{"type":"assistant/chunk","seq":11,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":12,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":13,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":14,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} -{"type":"assistant/chunk","seq":15,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} -{"type":"assistant/chunk","seq":16,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} -{"type":"assistant/chunk","seq":17,"time":1782993777789,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":18,"time":1782993777845,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":19,"time":1782993777846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":20,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":21,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":22,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":23,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":24,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":25,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":26,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":27,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"config"}}} -{"type":"assistant/chunk","seq":28,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":29,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":30,"time":1782993777960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":31,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."}}}} -{"type":"assistant/chunk","seq":32,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} -{"type":"assistant/chunk","seq":33,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":34,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1782993777991,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."},{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.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":1782993777991,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":37,"time":1782993777996,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","content":[{"type":"text","text":"/tmp/acp-snap-cwd-yl8qhJ/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[36],"surfaceOp":"append"} -{"type":"step/end","seq":38,"time":1782993777996,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":39,"time":1782993777996,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":40,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":41,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":42,"time":1782993778711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":43,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} -{"type":"assistant/chunk","seq":44,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":45,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mode"}}} -{"type":"assistant/chunk","seq":46,"time":1782993778740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} -{"type":"assistant/chunk","seq":47,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} -{"type":"assistant/chunk","seq":48,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} -{"type":"assistant/chunk","seq":50,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} -{"type":"assistant/chunk","seq":51,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":52,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":53,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":54,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":55,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":56,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":57,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":58,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} -{"type":"assistant/chunk","seq":59,"time":1782993778822,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":60,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" literal"}}} -{"type":"assistant/chunk","seq":61,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} -{"type":"assistant/chunk","seq":62,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":63,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} -{"type":"assistant/chunk","seq":64,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":65,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":66,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":67,"time":1782993778852,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RE"}}} -{"type":"assistant/chunk","seq":68,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LEASE"}}} -{"type":"assistant/chunk","seq":69,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":70,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":71,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":72,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} -{"type":"assistant/chunk","seq":73,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":74,"time":1782993778905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":75,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":76,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":77,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":78,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":80,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":81,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":82,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":83,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"config"}}} -{"type":"assistant/chunk","seq":85,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":86,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":87,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":88,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":89,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"old"}}} -{"type":"assistant/chunk","seq":90,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}} -{"type":"assistant/chunk","seq":91,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":92,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":93,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":94,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"DEBUG"}}} -{"type":"assistant/chunk","seq":95,"time":1782993779128,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":96,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":97,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":98,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"new"}}} -{"type":"assistant/chunk","seq":99,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}} -{"type":"assistant/chunk","seq":100,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":101,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":102,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":103,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"RE"}}} -{"type":"assistant/chunk","seq":104,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"LEASE"}}} -{"type":"assistant/chunk","seq":105,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":107,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} -{"type":"assistant/chunk","seq":108,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} -{"type":"assistant/chunk","seq":109,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":110,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":111,"time":1782993779276,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}},"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,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],"surfaceOp":"append"} -{"type":"tool/call","seq":112,"time":1782993779276,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":113,"time":1782993779282,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-yl8qhJ/config.txt has been updated successfully."}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1782993779282,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":115,"time":1782993779282,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":116,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":117,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":118,"time":1782993779945,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} -{"type":"assistant/chunk","seq":119,"time":1782993779978,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":120,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" successful"}}} -{"type":"assistant/chunk","seq":121,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":122,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":123,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":124,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":125,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":126,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":127,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":128,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":129,"time":1782993780029,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":130,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":131,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":132,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":133,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":134,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":135,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":136,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":137,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":138,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":139,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."}}}} -{"type":"assistant/chunk","seq":140,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":141,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":142,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":143,"time":1782993780064,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} -{"type":"step/end","seq":144,"time":1782993780064,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":145,"time":1782993780064,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"/tmp/acp-snap-cwd-qAWDep/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"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 index 8f5d02632d..c2a8fe005c 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -9,27 +9,10 @@ {"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":" 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":" its"}}}} -{"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\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":" 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":"mode"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" on"}}}} -{"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":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"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":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} +{"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"}}}} @@ -50,12 +33,13 @@ {"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_OwPhDMqS06VEbY7rO4Rv1204","title":"Edit config.txt","kind":"edit","status":"in_progress","rawInput":"\"DEBUG\" → \"RELEASE\"","locations":[{"path":"config.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","status":"completed","content":[{"type":"content","content":{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}}]}}} +{"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":" edit"}}}} -{"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":" successful"}}}} +{"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"}}}} 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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index f9433a8725..bad62dda0f 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,102 +1,126 @@ -{"type":"session","version":0,"id":"b9dfbc86-c33f-45ca-869a-49b62a94ea77","createdAt":1782993880851,"cwd":"/tmp/acp-snap-cwd-2yWjlu"} -{"type":"turn/start","seq":0,"time":1782993880856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1782993880856,"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":1782993880857,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":5,"time":1782993881583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":6,"time":1782993881612,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":7,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} -{"type":"assistant/chunk","seq":8,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":9,"time":1782993881614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} -{"type":"assistant/chunk","seq":10,"time":1782993881638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} -{"type":"assistant/chunk","seq":11,"time":1782993881669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} -{"type":"assistant/chunk","seq":12,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":13,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} -{"type":"assistant/chunk","seq":14,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":15,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":16,"time":1782993881671,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":17,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":18,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":19,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":20,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" offset"}}} -{"type":"assistant/chunk","seq":21,"time":1782993881698,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":22,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} -{"type":"assistant/chunk","seq":23,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":24,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" limit"}}} -{"type":"assistant/chunk","seq":25,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":26,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":27,"time":1782993881725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":28,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":30,"time":1782993881838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":31,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":32,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":33,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":34,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":36,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"big"}}} -{"type":"assistant/chunk","seq":38,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":39,"time":1782993881891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1782993881919,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":41,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"offset"}}} -{"type":"assistant/chunk","seq":43,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":45,"time":1782993881946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"5"}}} -{"type":"assistant/chunk","seq":46,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":47,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"limit"}}} -{"type":"assistant/chunk","seq":49,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":50,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":51,"time":1782993882029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"4"}}} -{"type":"assistant/chunk","seq":52,"time":1782993882058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":53,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."}}}} -{"type":"assistant/chunk","seq":54,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} -{"type":"assistant/chunk","seq":55,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}}}} -{"type":"assistant/chunk","seq":56,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":57,"time":1782993882089,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."},{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}},"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":1782993882089,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":59,"time":1782993882094,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","content":[{"type":"text","text":"/tmp/acp-snap-cwd-2yWjlu/big.txt\nfile\n\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"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} -{"type":"step/end","seq":60,"time":1782993882095,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":61,"time":1782993882095,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":62,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":63,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":64,"time":1782993882625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":65,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":66,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":67,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":68,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":69,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} -{"type":"assistant/chunk","seq":70,"time":1782993882654,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":71,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} -{"type":"assistant/chunk","seq":72,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} -{"type":"assistant/chunk","seq":73,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} -{"type":"assistant/chunk","seq":74,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":75,"time":1782993882707,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} -{"type":"assistant/chunk","seq":76,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":77,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":78,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":79,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":80,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":81,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":82,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":83,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":84,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":85,"time":1782993882736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":86,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":87,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} -{"type":"assistant/chunk","seq":88,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" done"}}} -{"type":"assistant/chunk","seq":89,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":90,"time":1782993882790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":91,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":92,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":93,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":94,"time":1782993882819,"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 that."}}}} -{"type":"assistant/chunk","seq":95,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":96,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":97,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":98,"time":1782993882820,"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 that."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":99,"time":1782993882820,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":100,"time":1782993882820,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"/tmp/acp-snap-cwd-ogYesF/big.txt\nfile\n\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"}],"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 index 577f8adaa6..f24c053d50 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,13 +1,25 @@ {"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":"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"}}}} @@ -17,15 +29,15 @@ {"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":"="}}}} {"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":"="}}}} {"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_0htYNlUzC9b8aH2gHN8h2706","title":"Read big.txt","kind":"read","status":"in_progress","rawInput":"offset 5, limit 4","locations":[{"path":"big.txt","line":5}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\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"}}]}}} +{"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":"{{cwd}}/big.txt\nfile\n\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"}}]}}} {"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"}}}} @@ -52,8 +64,20 @@ {"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":" 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":" 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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index c8e5d936d8..2a8d2d2640 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,93 +1,87 @@ -{"type":"session","version":0,"id":"01de71a7-68ef-469f-8a73-de9c1d7c55cf","createdAt":1782993863844,"cwd":"/tmp/acp-snap-cwd-WE9Cx4"} -{"type":"turn/start","seq":0,"time":1782993863849,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1782993863849,"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":1782993863850,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1782993864293,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1782993864294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1782993864378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1782993864407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":10,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":11,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} -{"type":"assistant/chunk","seq":12,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":13,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":14,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":15,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":16,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":17,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":18,"time":1782993864465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":19,"time":1782993864493,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":20,"time":1782993864494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":21,"time":1782993864519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":22,"time":1782993864520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":23,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":24,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":25,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":26,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":27,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":28,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":29,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":30,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":31,"time":1782993864662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":32,"time":1782993864663,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":33,"time":1782993864691,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":34,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":36,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":37,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":39,"time":1782993864720,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"gre"}}} -{"type":"assistant/chunk","seq":41,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"eting"}}} -{"type":"assistant/chunk","seq":42,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":43,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":45,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."}}}} -{"type":"assistant/chunk","seq":46,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} -{"type":"assistant/chunk","seq":47,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":48,"time":1782993864808,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":49,"time":1782993864810,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."},{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}},"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],"surfaceOp":"append"} -{"type":"tool/call","seq":50,"time":1782993864810,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":51,"time":1782993864815,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","content":[{"type":"text","text":"/tmp/acp-snap-cwd-WE9Cx4/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"} -{"type":"step/end","seq":52,"time":1782993864816,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":53,"time":1782993864816,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":54,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":55,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":56,"time":1782993866187,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":57,"time":1782993866215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} -{"type":"assistant/chunk","seq":58,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":59,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} -{"type":"assistant/chunk","seq":60,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} -{"type":"assistant/chunk","seq":62,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} -{"type":"assistant/chunk","seq":63,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":64,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":65,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":66,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":67,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":68,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":69,"time":1782993866302,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":70,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":71,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":72,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":73,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":74,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":75,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":76,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":77,"time":1782993866358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":78,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":79,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":80,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":81,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":82,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":83,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":84,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":85,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."}}}} -{"type":"assistant/chunk","seq":86,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":87,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":88,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":89,"time":1782993866388,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":90,"time":1782993866388,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":91,"time":1782993866389,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"/tmp/acp-snap-cwd-mYfeWU/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"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 index 1d19e735e4..30076deeeb 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -7,51 +7,45 @@ {"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":" file"}}}} {"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":" ("}}}} +{"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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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_6cBhaXfexPCkwewPFfJd4624","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"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":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\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":" 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":" on"}}}} -{"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":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":"\"."}}}} +{"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":" 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"}}}} 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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index ac97558243..8ad253a949 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,132 +1,124 @@ -{"type":"session","version":0,"id":"2b08a4bd-62f1-4846-b57f-7c62d4101673","createdAt":1782993794495,"cwd":"/tmp/acp-snap-cwd-X0UUW6"} -{"type":"turn/start","seq":0,"time":1782993794499,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1782993794499,"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":1782993794500,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1782993795030,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1782993795058,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":10,"time":1782993795087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":11,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} -{"type":"assistant/chunk","seq":12,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":13,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":14,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":15,"time":1782993795113,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} -{"type":"assistant/chunk","seq":16,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} -{"type":"assistant/chunk","seq":17,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} -{"type":"assistant/chunk","seq":18,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} -{"type":"assistant/chunk","seq":19,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":20,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":21,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} -{"type":"assistant/chunk","seq":22,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} -{"type":"assistant/chunk","seq":23,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} -{"type":"assistant/chunk","seq":24,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":25,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":26,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":27,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":28,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":29,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":30,"time":1782993795199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":31,"time":1782993795230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":32,"time":1782993795313,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1782993795314,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":34,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":35,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":37,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":38,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":40,"time":1782993795372,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"data"}}} -{"type":"assistant/chunk","seq":42,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":43,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":45,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":46,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} -{"type":"assistant/chunk","seq":47,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":48,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":49,"time":1782993795468,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}},"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],"surfaceOp":"append"} -{"type":"tool/call","seq":50,"time":1782993795468,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":51,"time":1782993795473,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","content":[{"type":"text","text":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"} -{"type":"step/end","seq":52,"time":1782993795473,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":53,"time":1782993795473,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":54,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":55,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} -{"type":"assistant/chunk","seq":56,"time":1782993796250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":57,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":58,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":59,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} -{"type":"assistant/chunk","seq":60,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":61,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} -{"type":"assistant/chunk","seq":62,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} -{"type":"assistant/chunk","seq":63,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":64,"time":1782993796310,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} -{"type":"assistant/chunk","seq":65,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":66,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":67,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":68,"time":1782993796339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":69,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":70,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} -{"type":"assistant/chunk","seq":71,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":72,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} -{"type":"assistant/chunk","seq":73,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} -{"type":"assistant/chunk","seq":74,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":75,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":76,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":77,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":78,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":80,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":81,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":82,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":83,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"data"}}} -{"type":"assistant/chunk","seq":85,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":86,"time":1782993796536,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":87,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":88,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":89,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":90,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":91,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":92,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":93,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"re"}}} -{"type":"assistant/chunk","seq":94,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"placed"}}} -{"type":"assistant/chunk","seq":95,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":96,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":97,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."}}}} -{"type":"assistant/chunk","seq":98,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} -{"type":"assistant/chunk","seq":99,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":100,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":101,"time":1782993796681,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."},{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"tool/call","seq":102,"time":1782993796681,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":103,"time":1782993796688,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","content":[{"type":"text","text":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\nUpdated file\n"}],"isError":false},"sourceEventSeqs":[102],"surfaceOp":"append"} -{"type":"step/end","seq":104,"time":1782993796689,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":105,"time":1782993796689,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":106,"time":1782993797188,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":107,"time":1782993797189,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}} -{"type":"assistant/chunk","seq":108,"time":1782993797260,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":109,"time":1782993797289,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":110,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":111,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":112,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":113,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":114,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":115,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":116,"time":1782993797354,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":117,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":118,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":119,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":120,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":121,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":122,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":123,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":124,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."}}}} -{"type":"assistant/chunk","seq":125,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":126,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":127,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":128,"time":1782993797386,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}},"sourceEventSeqs":[106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} -{"type":"step/end","seq":129,"time":1782993797386,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":130,"time":1782993797386,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"/tmp/acp-snap-cwd-u64NRw/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"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":"/tmp/acp-snap-cwd-u64NRw/data.txt\nfile\n\nUpdated file\n"}],"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 index 1d234c2dd4..504483c7b9 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,35 +1,21 @@ {"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":"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":" to"}}}} -{"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":" read"}}}} +{"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":","}}}} -{"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":" replace"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":" \""}}}} -{"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":" 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_GtqlR9riew6wgdQzLbbu6019","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"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":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} {"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"}}}} @@ -38,23 +24,29 @@ {"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":" of"}}}} -{"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":" 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":"tool_call","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} -{"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":"."}}}} +{"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 533ec83084..ff637f94a1 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,95 +1,96 @@ -{"type":"session","version":0,"id":"5475c102-9aaa-4952-8a48-d5c3444eb322","createdAt":1782993761947,"cwd":"/tmp/acp-snap-cwd-v8qbp7"} -{"type":"turn/start","seq":0,"time":1782993761951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1782993761952,"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":1782993761953,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1782993762528,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1782993762529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1782993762648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} -{"type":"assistant/chunk","seq":10,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":11,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":12,"time":1782993762704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} -{"type":"assistant/chunk","seq":13,"time":1782993762731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} -{"type":"assistant/chunk","seq":14,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":15,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":17,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":18,"time":1782993762787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":19,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} -{"type":"assistant/chunk","seq":20,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} -{"type":"assistant/chunk","seq":21,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":22,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":23,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":24,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":25,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":26,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":27,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":28,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":29,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":30,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":31,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":32,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":33,"time":1782993762844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":34,"time":1782993762871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":35,"time":1782993762926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1782993762927,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":37,"time":1782993762953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":38,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":40,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":41,"time":1782993762987,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":43,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"notes"}}} -{"type":"assistant/chunk","seq":45,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":46,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1782993763040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":48,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":50,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1782993763068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":52,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"hello"}}} -{"type":"assistant/chunk","seq":54,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":" world"}}} -{"type":"assistant/chunk","seq":55,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":57,"time":1782993763155,"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, and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":58,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":60,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1782993763157,"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, and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","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":1782993763157,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1782993763164,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","content":[{"type":"text","text":"/tmp/acp-snap-cwd-v8qbp7/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":64,"time":1782993763164,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":65,"time":1782993763165,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":66,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":67,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":68,"time":1782993763841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":69,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":70,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} -{"type":"assistant/chunk","seq":71,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":72,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":73,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":74,"time":1782993763900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":75,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":76,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":77,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":78,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":79,"time":1782993763930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":80,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":81,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":82,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":83,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":84,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":85,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":86,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":87,"time":1782993763958,"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 \"DONE\"."}}}} -{"type":"assistant/chunk","seq":88,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":89,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":90,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":91,"time":1782993763958,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":92,"time":1782993763958,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":93,"time":1782993763959,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"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":"/tmp/acp-snap-cwd-bol9fl/notes.txt\nfile\n\nCreated file\n"}],"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 index daa26245bb..ba9d2e691f 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -23,16 +23,16 @@ {"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":" 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":"tool_call","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} +{"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"}}}} @@ -46,6 +46,7 @@ {"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"}}}} 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 d8b764483d..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 `read`/`write`/`edit` filesystem tools + 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,7 +8,7 @@ The real stdio coding-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` file # 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 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. @@ -26,7 +26,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem 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= pnpm run demo:coding +RESUME_SESSION_ID= pnpm run demo:repl ``` The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. @@ -37,7 +37,7 @@ 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 | 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 @@ + + +# 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
cordis.yml"] + plugin_coding_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_coding_hmr + plugin_coding_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_coding_llm_deepseek + plugin_coding_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_coding_bash + plugin_coding_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-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
console logger
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
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_coding_compact_basic + plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_coding_subagent + plugin_coding_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_coding_subagent_spawn + plugin_coding_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_coding_subagent_fork + plugin_coding_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_coding_tool_subagent + plugin_coding_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_coding_tool_subagent_fork + plugin_coding_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_coding_tool_todo + plugin_coding_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_coding_fs_local + plugin_coding_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_coding_fs_policy + plugin_coding_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_coding_tool_fs +``` + +| Plugin 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 625fa0c9b9..0c95299fca 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. @@ -37,7 +37,7 @@ 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,7 +46,7 @@ # 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 read, write, edit, bash, subagent, and todo_write).' + welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).' systemPrompt: | You are coding-agent, a CLI coding assistant. 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/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/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 @@ + + +# 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
cordis.yml"] + plugin_echo_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_echo_hmr + plugin_echo_mock_llm["mock-llm
./src/mock-llm.ts"] + cfg --> plugin_echo_mock_llm + plugin_echo_echo_tool["echo-tool
./src/echo-tool.ts"] + cfg --> plugin_echo_echo_tool + plugin_echo_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_echo_bash + plugin_echo_stdio_agent["stdio-agent
@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
console logger
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/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 9e5829317d..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"] diff --git a/package.json b/package.json index 7d82af4aad..8a0218f1e7 100644 --- a/package.json +++ b/package.json @@ -29,35 +29,46 @@ "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-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-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.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). -- **Tests**: vitest in `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env. +- **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 29cef0b9c0..f07e0d5a36 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,10 +1,10 @@ # 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///`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure. +Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-` regardless of group. **Each group 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 | |---|---|---| @@ -14,106 +14,20 @@ Packages are grouped by modular role at `packages///`. The group dir | [`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 | +| [`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` 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 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-user-interaction ← dsh-agent, dsh-llm -dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred) -dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend) -dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent -dsh-tool-ask-user ← dsh-tools, dsh-user-interaction -dsh-bash-local ← dsh-bash (BashExecutor impl) -dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) -dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) -dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service) -dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor) -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, dsh-user-interaction (ACP JSON-RPC bridge + user-interaction provider) -dsh-ui-stdio ← dsh-agent, dsh-session, dsh-user-interaction (stdio readline UI plugin + user-interaction provider) -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-user-interaction, dsh-tool-ask-user, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) -dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-user-interaction, dsh-tool-ask-user, 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` | -| `user-interaction/` | `core` | Abstract human question/answer seam | `ctx.userInteraction` | -| `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`) | -| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | -| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | -| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | -| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | -| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | -| `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`) | -| `tool-ask-user/` | `ui` | Model-facing `ask_user_question` tool | (registers on `ctx.tools`) | -| `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` 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///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..c13a3ab923 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -12,6 +12,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L timeoutMs: 120000 # default foreground timeout maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills ``` ## Behavior (and where it came from) @@ -19,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 § plugin checklist. 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..6df7b3da7b 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -4,8 +4,8 @@ * 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 + * TODO(permissions/sandbox): execution policy does NOT belong here — use + * the `tools/pre-execute` deny/ask gate (see docs/architecture.md § plugin * checklist) 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 +17,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' -import { runBash } from './run.ts' +import { DEFAULT_GRACE_MS, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' @@ -33,6 +33,8 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number + /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + graceMs?: number } /** The shape after schemastery applied the defaults (cwd has none). */ @@ -57,7 +59,7 @@ interface TrackedTask extends BashTask { * Local-subprocess bash executor. Defaults follow the agent-tool survey * consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB * in-memory output with full-stream spill files (pi, OpenCode), - * process-group SIGTERM→SIGKILL kills (OpenCode). + * process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode). */ export class LocalBashExecutor extends BashExecutor { static Config: z = z.object({ @@ -65,11 +67,12 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), + graceMs: z.number().default(DEFAULT_GRACE_MS), }) private tasks = new Map() private nextTaskId = 1 - /** Test seam: timer/spill knobs forwarded to runBash. */ + /** Test seam: spill knobs forwarded to runBash. */ internals: RunInternals = {} /** Validated config (schemastery applied the defaults before construction). */ @@ -83,6 +86,7 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('graceMs', this.config.graceMs) ctx.effect(() => async () => { // Kill every live process group and WAIT for the processes to close so // nothing outlives the fiber (HMR safety) — a TERM-trapping child is @@ -116,6 +120,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 +136,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 +155,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): 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 | 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 = 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((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[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[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 - /** 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 | 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 | 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..436e3f034a 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,12 +34,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/src/index.ts b/packages/bash/tool-bash/src/index.ts index 9ad1f9a17c..88d14cc0f0 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -31,7 +31,7 @@ * 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. * @@ -41,7 +41,7 @@ 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 { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' @@ -158,16 +158,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 +196,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 +251,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 } } /** 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..0187302ef1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -21,8 +21,8 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -184,8 +184,8 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -316,8 +316,8 @@ describe('background tools', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true }) @@ -568,8 +568,8 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } const fiber = await ctx.plugin(ToolBash) const a = fakeAgent('sess-a') @@ -716,45 +716,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 +774,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 +789,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 +843,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 +857,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 { + 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 `` 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 /** - * 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 * 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))) .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 4ac11609c7..a2d01aa0df 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,8 +6,8 @@ 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` | -| `user-interaction/` | Human question/answer seam for tools and permission flows | `ctx.userInteraction` | +| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` | +| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `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..022ccba4f4 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -13,7 +13,7 @@ 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 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5932bc6741..60b8a5d779 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -45,10 +45,14 @@ 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() @@ -58,10 +62,14 @@ forever: 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 +83,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..33672dc9b4 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' @@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. + * @param id - the agent id; also seeds the generated session id. + * @param options - loop options (model, limits, …); defaults applied per option. + * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) @@ -129,7 +132,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 +145,9 @@ export class AgentLoop extends Service implements AgentFactory { * `seed` (a balanced completed-turn prefix of the parent's log) so the child * starts with the parent's context. Returns an {@link AgentHandle} the owner * disposes to tear down exactly this agent. + * @param options - agent id, caller-supplied session id, optional seed/meta, + * and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a @@ -152,7 +158,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 +174,8 @@ export class AgentLoop extends Service implements AgentFactory { * configured. NOT hard-injected (that would make non-persistent demos pend * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. + * @param options - the persisted session id to reload, plus agent id/options. + * @returns the handle for the agent resumed on the reconstructed session. */ async resume(options: ResumeAgentOptions): Promise { // Read the service through `ctx.get('sessionPersistence')` — a direct @@ -224,7 +234,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 +271,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 } { + private start( + id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { const agent = new ReactLoopAgent(this.ctx, id, options, session) const dispose = this.ctx.effect(function* (this: AgentLoop) { yield this.ctx.sessions.enter(session) this.ctx.sessions.announce(session) yield this.ctx.agents.register(agent) + // 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 +324,8 @@ export class AgentLoop extends Service implements AgentFactory { * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ - private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle { - const { agent, disposeAgent } = this.start(id, options, session) + private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle { + const { agent, disposeAgent } = this.start(id, options, session, source) let disposing: Promise | 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..ef5c50b7ce 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,37 @@ 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 * 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 +285,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 +327,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 +367,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({ 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 @@ -432,24 +466,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 +529,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 +546,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 +577,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 +587,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 +635,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } /** Drain the steering queue into the session. Returns whether any arrived. */ -function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean { +function drainSteering(agent: ReactLoopAgent, turn: number): boolean { const messages = agent.inbox.drainSteering() for (const message of messages) { agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) - ctx.emit('agent/steering', agent, turn, message.content, message.source) } return messages.length > 0 } @@ -636,7 +680,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 +738,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 +754,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 +771,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 +779,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 +793,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 | 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/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 { + 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 => + ({ 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 => + ({ + 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 => + ({ + 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 => + ({ 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 => { + 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 => { + 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 => ({ 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 => + ({ 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 => { + 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 => { + 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 => { + 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 => { + 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..934c19953a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -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,6 +115,32 @@ describe('agent loop', () => { expect(types).toContain('tool/result') }) + 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) + // 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('passes assembled system prompt and tool schemas into the request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -132,21 +163,17 @@ describe('agent loop', () => { expect(request!.tools?.map(t => t.name)).toEqual(['noop']) }) - it('records raw chunks for replay and emits agent/stream-chunk', async () => { + 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 +296,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 +321,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) @@ -430,7 +457,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 +477,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 +502,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 +533,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 +566,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 +608,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 +627,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 +667,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 +683,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 +704,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 +751,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) 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..76c377fd61 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 => { + ctx.on('agent/turn-continuation', async (): Promise => { 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((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' }) @@ -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 () => { @@ -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)) @@ -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/src/index.ts b/packages/core/agent/src/index.ts index 940a5c9db1..ec9ba796b9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -126,6 +126,8 @@ export class AgentRegistry extends Service { * Register the agent-creation factory (the loop calls this on construction, * effect-scoped). Throws if a factory is already registered. Returns the * disposer; on dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @returns the disposer that clears the factory slot. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { @@ -142,6 +144,8 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Throws if no factory is * registered. Returns an {@link AgentHandle} — the owner disposes it to tear * down exactly this agent. + * @param options - agent id, session id/seed/metadata, and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ create(options: CreateAgentOptions): AgentHandle { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -152,6 +156,8 @@ export class AgentRegistry extends Service { * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured. Returns an {@link AgentHandle}. + * @param options - the persisted session id plus agent id and options. + * @returns the handle for the resumed agent. */ async resume(options: ResumeAgentOptions): Promise { 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..2a5d713b85 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,11 +6,45 @@ * 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' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -19,7 +53,7 @@ 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' /** * Options an agent is created with. @@ -38,6 +72,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 +228,14 @@ declare module 'cordis' { /** * An agent was registered in the {@link AgentRegistry} and is ready to * receive messages. + * @param agent - the newly registered agent, already resolvable in the registry. * @mode emit */ 'agent/created'(agent: Agent): void /** * An agent was disposed and removed from the registry; its fiber and any * in-flight turn have been torn down. + * @param agent - the agent that was torn down; its handle is now inert. * @mode emit */ 'agent/disposed'(agent: Agent): void @@ -145,39 +243,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 +304,11 @@ declare module 'cordis' { * listener needs to measure pressure (the system prompt counts toward the * budget). `signal` cancels any in-flight work a listener starts (e.g. a * summarization model call). + * @param agent - the agent about to open the step. + * @param turn - the already-open turn this step belongs to. + * @param step - the number of the step about to start. + * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. + * @param signal - aborts in-flight listener work when the turn is torn down. * @mode serial */ // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction @@ -212,43 +317,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 + /** + * 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): Promise /** * 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): Promise /** * 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): Promise /** - * Waterfall: override the turn-continuation decision. The default - * (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners - * can force-continue (/goal, /loop) or force-stop (budget guards). + * 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): Promise + 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise - // ---- 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): Promise', + ' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise): Promise', )) 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): Promise', + ' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise): Promise', ))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/) }) it('hard-errors when @mode waterfall has no trailing next to delegate to', () => { expect(() => collectEvents(make( - ' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', + ' /**\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): Promise', + )) + 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 needs no @returns either). */ + flush(): Promise { 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. surface)', () => { + const services = collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}', + )) + expect(services[0]?.methods).toHaveLength(0) + }) }) diff --git a/packages/core/session/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 @@ -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-`. + * @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-`. + * @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 { + 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 => { + 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/src/index.ts b/packages/core/system-prompt/src/index.ts index a5e6e4ed78..37dc10b9ee 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -19,6 +19,8 @@ declare module 'cordis' { * Waterfall around prompt assembly — mutate or extend the * {@link PromptAssembly} (sections + tool schemas) before it is rendered. * Bound to the {@link SystemPrompt} service; call `next()` to delegate. + * @param assembly - the assembly built from the registered sections and + * tool providers; listeners may mutate it or return a replacement. * @mode waterfall */ 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise @@ -80,6 +82,8 @@ export class SystemPrompt extends Service { * Contribute a text section to the system prompt. Order is determined by * `section.order` (ascending). The section is removed when the calling * fiber is disposed. Emits `system-prompt/change` on register/unregister. + * @param section - the section to contribute (name, order, text or provider). + * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -105,6 +109,8 @@ export class SystemPrompt extends Service { * Contribute a tool-schema provider that is evaluated at each assembly * call (so it can reflect the live registry state). The provider is * removed when the calling fiber is disposed. Emits `system-prompt/change`. + * @param provider - evaluated at every {@link assemble} for fresh schemas. + * @returns the disposer that removes the provider. */ tools(provider: () => ToolSchema[]): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -132,6 +138,7 @@ export class SystemPrompt extends Service { * listeners the opportunity to mutate or replace the assembly before it * reaches the model. Await the result before reading the assembly values — * waterfall listeners may be async. + * @returns the assembly after the waterfall has run. */ assemble(): Promise { const assembly: PromptAssembly = { diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 01a2e09d3b..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` Execute one tool call through the `tools/execute` waterfall. +- `ctx.tools.execute(exec: ToolExecution): Promise` 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`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). +- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (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), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), 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` 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` 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 c2f324e87b..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): Promise + 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise + /** + * 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): Promise /** * A tool was registered or unregistered (the available tool set changed). * @mode emit @@ -55,157 +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[] - /** - * Files this call reads or modifies, so a capable UI can "follow along" — - * highlight or jump to the file (and line) as the tool runs. Provider-neutral - * `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP - * bridge forwards them 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). Omit for a call that touches no file (e.g. - * `bash`). - */ - locations?: { path: string; line?: number }[] - /** - * 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 + 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 } /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ @@ -214,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 @@ -257,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` @@ -281,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'] @@ -299,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) { @@ -322,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 { try { - return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise => { - 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({ 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 { + // 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({ 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 { parameters: S /** * Tool execution function. `args` is typed as {@link InferArgs} — 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, exec: ToolExecution): Promise + execute(args: InferArgs, exec: ToolExecution): Promise /** * 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): ToolCallPresentation | undefined + presentCall?(args: InferArgs): 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, result: ToolResult): ToolResultPresentation | undefined - /** Whether the tool requires structured output (default false). */ - strict?: boolean + presentResult?(args: InferArgs, result: ToolResult): ToolResultView | undefined } /** @@ -353,8 +353,7 @@ export function defineTool(options: DefineToolOptions): name: options.name, description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, - ...options.strict !== undefined ? { strict: options.strict } : {}, - async execute(args: unknown, exec: ToolExecution): Promise { + async execute(args: unknown, exec: ToolExecution): Promise { // 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(options: DefineToolOptions): // 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) } } 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, result) } diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 7e30c4199a..ac857df05a 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(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'write']) + expect(names).toEqual(['ask_user_question', '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 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 => { - 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 => { + 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 => + ({ 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 => ({ 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 => + ({ 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 => + ({ 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 => + ({ + 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 => + ({ 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/core/user-interaction/README.md b/packages/core/user-interaction/README.md index 0576495644..66f812997f 100644 --- a/packages/core/user-interaction/README.md +++ b/packages/core/user-interaction/README.md @@ -19,4 +19,4 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI implementations such as `@deepseek-ai/dsh-ui-stdio` provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop. diff --git a/packages/core/user-interaction/src/index.ts b/packages/core/user-interaction/src/index.ts index ad6c1dde8e..2e560d9574 100644 --- a/packages/core/user-interaction/src/index.ts +++ b/packages/core/user-interaction/src/index.ts @@ -74,7 +74,12 @@ export class UserInteractionService extends Service { super(ctx, 'userInteraction') } - /** Register the UI provider. Only one provider may be active in a context. */ + /** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ registerProvider(provider: UserInteractionProvider): () => void { const dispose = this.ctx.effect(function* (this: UserInteractionService) { if (this.provider !== undefined) { @@ -88,7 +93,12 @@ export class UserInteractionService extends Service { return () => void dispose() } - /** Ask the active UI provider and wait for the user's answer. */ + /** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Question, options, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6fb75276e0..d7bce1d3a7 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-fs-local -The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +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' @@ -15,6 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`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`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b24b7e6b89..b1b9c25397 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -20,15 +20,12 @@ import { randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' -import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises' -import type { Stats } 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' -/** Files at or above this size stream their text; smaller files read whole. */ -export const STREAM_MIN_SIZE = 10 * 1024 * 1024 - const BINARY_SAMPLE_BYTES = 8192 function isENOENT(error: unknown): boolean { @@ -55,6 +52,10 @@ function errorMessage(error: unknown): string { } /* 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') } @@ -81,13 +82,11 @@ function versionOf(info: Stats): FsVersion { } /** - * Test seam: lets specs force the streaming read path (via a small - * `streamMinSize`) and pin the temp-file name (to prove exclusive-open - * behavior) without a 10 MB fixture or a name race. + * Test seam: lets specs pin the atomic-write temp names (to prove + * exclusive-open behavior without a name race) and observe the staged temp + * file before it is renamed over the target. */ export interface FsIoInternals { - /** Override {@link STREAM_MIN_SIZE} for read routing. */ - streamMinSize?: number /** Override the generated private staging-dir name (relative to the target dir). */ tempDirName?: (writePath: string) => string /** Override the generated temp-file name (relative to the private staging dir). */ @@ -112,6 +111,15 @@ export interface PathInfo { 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 @@ -172,6 +180,68 @@ export async function probe(absolutePath: string): Promise { } } +// --- 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 { + 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 { + 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 { @@ -382,6 +452,26 @@ export async function readForEdit( 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 { + 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 @@ -410,4 +500,4 @@ export function applyLiteralEdit( return { content: content.split(oldNorm).join(newNorm), replacements } } -export { restoreLineEndings } +export { normalizeLineEndings, restoreLineEndings } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 97dda3c4dd..0ad8365d4a 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,6 +1,6 @@ /** * Local-filesystem implementation of the `ctx.fs` provider seam. - * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six + * {@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 @@ -17,6 +17,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -26,8 +27,11 @@ import type { } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, + listDirectory, + normalizeLineEndings, probe, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -37,17 +41,18 @@ import { import type { FsIoInternals } from './fsio.ts' export { - STREAM_MIN_SIZE, applyLiteralEdit, + listDirectory, probe, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, streamWholeText, writeFileAtomic, } from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { @@ -99,7 +104,7 @@ export class LocalFileSystem extends FileSystem { override async resolve(path: string, opts?: { cwd?: string }): Promise { const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) - return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } + return { targetKey: local.targetKey, displayPath: local.displayPath } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { @@ -117,6 +122,17 @@ export class LocalFileSystem extends FileSystem { return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) } + override async listDir(target: FsTarget, signal?: AbortSignal): Promise { + 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, @@ -143,11 +159,25 @@ export class LocalFileSystem extends FileSystem { // 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), } }) } @@ -180,9 +210,11 @@ export class LocalFileSystem extends FileSystem { const after = await probe(target.targetKey) return { - replacements: edited.replacements, - replaceAll: edit.replaceAll, version: this.versionAfterWrite(after, target), + // The LF-normalized before/after text (the applied-hunk diff basis); + // line-ending restoration is a storage detail the diff ignores. + before: original.content, + after: edited.content, } }) } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 03c751a538..997021d346 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -7,7 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' +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' @@ -118,6 +118,50 @@ describe('readText / streamText', () => { }) }) +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') @@ -188,6 +232,53 @@ describe('writeText', () => { 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' }) @@ -238,10 +329,21 @@ describe('editText', () => { await writeFile(join(dir, 'a.txt'), 'hello world') const target = await fs.resolve('a.txt') const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) }) - expect(outcome.replacements).toBe(1) + expect(outcome.after).toBe('hello there') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) + 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') @@ -257,7 +359,7 @@ describe('editText', () => { const target = await fs.resolve('a.txt') // No version guard: any current content is edited, regardless of version. const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }) - expect(outcome.replacements).toBe(1) + expect(outcome.after).toBe('hello there') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) @@ -303,7 +405,7 @@ describe('editText', () => { await writeFile(join(dir, 'a.txt'), 'a a a') const target = await fs.resolve('a.txt') const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) }) - expect(outcome.replacements).toBe(3) + expect(outcome.after).toBe('b b b') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') }) @@ -349,7 +451,7 @@ describe('editText', () => { // The version the first edit returned is a valid guard for a second edit — // no intervening re-stat needed. const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version }) - expect(second.replacements).toBe(1) + expect(second.after).toBe('ONE TWO') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO') }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 0b16e9e2c8..3a30f73ed2 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -6,12 +6,13 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' +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, @@ -145,6 +146,110 @@ describe('probe', () => { }) }) +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') diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 2ea61ffcd1..c41cf45701 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -18,7 +18,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' function target(path: string): FsTarget { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + return { targetKey: FsTargetKey(path), displayPath: path } } const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 3cea538ff7..ac2866802b 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-fs -The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, 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. +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)): @@ -15,14 +15,15 @@ A future sandboxed, virtual, or remote backend implements this interface and the ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements six primitives. +A backend subclasses `FileSystem` and implements seven primitives. | Member | Semantics | |---|---| -| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | +| `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. | @@ -30,7 +31,7 @@ The mutation runs inside the backend's per-target lock either way, so an uncondi ## The `fs/*` policy events -This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.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. +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 @@ -40,4 +41,4 @@ This package declares three events (see the generated [catalog](../../../docs/co ## 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_TEXT`, `FS_NOT_REGULAR_FILE`, `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. +`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/src/index.ts b/packages/fs/fs/src/index.ts index e3a8a36b66..1e0ab03b85 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -59,6 +59,7 @@ import { Context, Service } from 'cordis' import type { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -76,6 +77,7 @@ export { export type { FsEditOutcome, FsEditRequest, + FsDirEntry, FsErrorCode, FsInfo, FsTarget, @@ -100,6 +102,8 @@ declare module 'cordis' { * chain. The slot is first-wins: the first non-`next()` decider (registration * order, or `prepend`) occupies it; a second decider is a misconfiguration, * not layering. `actor` is the opaque tool-execution context, never read here. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise @@ -112,6 +116,8 @@ declare module 'cordis' { * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset * or has not observed the target. Does NOT call `next()`: one decision, * first-wins (see {@link Events.'fs/write-intent'}). + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> @@ -124,6 +130,9 @@ declare module 'cordis' { * await listener promises — async or fallible audit/telemetry does not * belong here. No listener ⇒ nothing recorded. `actor` is the opaque * tool-execution context. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -131,7 +140,7 @@ declare module 'cordis' { } /** - * Abstract filesystem provider service. Subclass, implement the six text-storage + * 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). @@ -145,6 +154,11 @@ declare module 'cordis' { * - {@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. @@ -173,13 +187,26 @@ export abstract class FileSystem extends Service { * caller's per-session workspace (`exec.agent.session.header.cwd`) without the * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` * defaults a bash `workdir` to the session cwd. + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @returns the stable target; the same file yields the same `targetKey`. */ abstract resolve(path: string, opts?: { cwd?: string }): Promise - /** Return target metadata, or `undefined` when the target does not exist. */ + /** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise - /** Read the whole regular text file as a single decoded string. */ + /** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise /** @@ -187,14 +214,31 @@ export abstract class FileSystem extends Service { * semantics as {@link readText}, for large files). The backend owns * cross-chunk UTF-8 decoding and binary rejection so the policy layer never * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + /** + * 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 + /** * 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 @@ -204,6 +248,11 @@ export abstract class FileSystem extends Service { * matching; OMITTING it edits the current content unconditionally (no version * guard). Either way applies the replacement and writes atomically — one * mutation critical section — and a missing target reports `FS_STALE_VERSION`. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise } diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 15a58ee93b..9351a373db 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -52,8 +52,6 @@ export function FsVersion(v: string): FsVersion { * this; every other operation takes it. */ export interface FsTarget { - /** The original model/plugin-supplied path, for diagnostics only. */ - inputPath: string /** Opaque key for stale guards and target lookup. */ targetKey: FsTargetKey /** @@ -78,6 +76,23 @@ export interface FsInfo { 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 @@ -101,6 +116,16 @@ export interface FsWriteOutcome { 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. */ @@ -115,12 +140,16 @@ export interface FsEditRequest { /** Outcome of a literal edit. */ export interface FsEditOutcome { - /** Number of literal replacements applied. */ - replacements: number - /** Whether every match was replaced. */ - replaceAll: boolean /** Opaque version of the file after the edit. */ version: FsVersion + /** + * 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 } /** @@ -130,8 +159,11 @@ export interface FsEditOutcome { */ 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' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index a0032afdee..86ba782c96 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -9,6 +9,7 @@ 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, @@ -17,12 +18,12 @@ import type { FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A minimal in-memory fake implementing the six provider primitives. */ +/** A minimal in-memory fake implementing the seven provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + return { targetKey: FsTargetKey(path), displayPath: path } } override async stat(target: FsTarget): Promise { const content = this.files.get(target.targetKey) @@ -38,15 +39,28 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } + override async listDir(target: FsTarget): Promise { + 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 { - const existed = this.files.has(target.targetKey) + const before = this.files.get(target.targetKey) ?? null this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } } override async editText(target: FsTarget, edit: FsEditRequest): Promise { const content = this.files.get(target.targetKey) ?? '' - this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { version: FsVersion('v3'), before: content, after } } } @@ -86,6 +100,20 @@ describe('FileSystem provider seam', () => { 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) diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index dacef45590..fedd1ff7a2 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -11,11 +11,22 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. +## Config + +All keys are optional; the defaults are the shipped read caps. + +| Key | Default | Meaning | +|---|---|---| +| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | +| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | +| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | +| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. | + ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) | Tool | Arguments | Behavior | |---|---|---| -| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | +| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 080b9a8f78..7e7b78aa38 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -21,9 +21,14 @@ "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" 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 + 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).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 index 1fb76ffd3c..5ede545b03 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -14,10 +14,11 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' /** Validated `edit` arguments after defaulting. */ @@ -41,9 +42,9 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new } } -/** Format an edit outcome as a Claude-style model-facing success message. */ -export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string { - return outcome.replaceAll +/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */ +export function formatEditOutput(displayPath: string, replaceAll: boolean): string { + return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.` } @@ -65,7 +66,7 @@ export function applyEditTool(ctx: Context): void { 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 { + 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) @@ -81,20 +82,39 @@ export function applyEditTool(ctx: Context): void { ) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] - }, - // Pure display: `edit` kind, a location for editor follow-along, and a short - // old→new summary as rawInput (truncated so a large replacement stays a - // readable card). The replacement COUNT is not available here — presentResult - // only sees `{ content, isError }`, not the outcome — so the title is static. - presentCall(args) { - const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}…` : s) + // 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}`, - kind: 'edit', - rawInput: `${JSON.stringify(clip(args.old_string))} → ${JSON.stringify(clip(args.new_string))}`, + 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 index e9f384a96c..f5d0d9ef91 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -23,15 +23,20 @@ */ import type { Context } from 'cordis' -import { applyReadTool } from './read.ts' +import z from 'schemastery' +import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' +import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' +export type { ReadToolCaps } from './read.ts' export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts' 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' @@ -39,9 +44,49 @@ export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ export const inject = ['tools', 'fs', 'systemPrompt'] +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Default and maximum number of lines returned by one `read` call. */ + readLimit?: number + /** Maximum characters returned for a single line before truncation. */ + readMaxLineLength?: number + /** Maximum bytes returned for the selected lines of one `read` call. */ + readMaxBytes?: number + /** Files at or above this size stream instead of loading whole into memory. */ + readStreamMinSize?: number +} + +export const Config: z = 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 + +/** 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): void { - applyReadTool(ctx) +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 index 97a1384792..c54279baa9 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -17,23 +17,23 @@ */ import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsVersion } from '@deepseek-ai/dsh-fs' -/** Maximum characters returned for a single line. */ +/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */ export const READ_MAX_LINE_LENGTH = 2000 -/** Maximum bytes returned for selected file lines. */ +/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */ export const READ_MAX_BYTES = 50 * 1024 -const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` -const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 - /** Resolved read window. The consumer applies its defaults/caps before calling. */ export interface ReadWindow { /** 1-based first line to return. */ offset: number /** Maximum number of lines to return. */ limit: number + /** Maximum characters returned for a single line; overflow is truncated with a suffix. */ + maxLineLength: number + /** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */ + maxBytes: number } /** One line returned from a text file. */ @@ -58,16 +58,12 @@ export interface WindowResult { export interface FileReadOutcome { /** 1-based first line requested. */ offset: number - /** Maximum number of lines requested. */ - limit: number /** Returned lines, already numbered. */ lines: FileTextLine[] /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ totalLines: number /** Whether selected output hit the byte cap before EOF or the requested limit. */ truncatedByBytes?: true - /** Opaque version of the file at read time. */ - version: FsVersion } interface WindowAccumulator { @@ -82,8 +78,8 @@ function newAccumulator(): WindowAccumulator { return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } } -function truncateLine(line: string): string { - return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line +function truncateLine(line: string, maxLineLength: number): string { + return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line } function lineByteSize(line: string, currentLineCount: number): number { @@ -94,9 +90,9 @@ function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindo acc.totalLines += 1 if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - const text = truncateLine(rawLine) + const text = truncateLine(rawLine, request.maxLineLength) const bytes = lineByteSize(text, acc.lines.length) - if (acc.outputBytes + bytes > READ_MAX_BYTES) { + if (acc.outputBytes + bytes > request.maxBytes) { acc.truncatedByBytes = true acc.done = true return @@ -121,7 +117,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string * Accepts an `AsyncIterable` (a chunked `streamText`) or an * `Iterable` (a whole-file `readText` wrapped as `[text]`), so one code * path serves both. Scans for newlines with a capped line buffer (a newline-free - * giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}), + * giant line is truncated, never buffered past `request.maxLineLength`), * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. */ export async function buildWindow( @@ -130,12 +126,14 @@ export async function buildWindow( displayPath: string, ): Promise { const acc = newAccumulator() + // One char past the truncation point is enough to prove a line overflows. + const lineBufferCap = request.maxLineLength + 1 let lineBuffer = '' function appendToLineBuffer(segment: string): void { - if (lineBuffer.length >= LINE_BUFFER_CAP) return + if (lineBuffer.length >= lineBufferCap) return lineBuffer += segment - if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) + if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap) } function flushLine(): void { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 17fa7aa7ab..b211d6d80e 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -14,6 +14,7 @@ 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' @@ -22,12 +23,27 @@ import { buildWindow, formatReadOutput } from './read-render.ts' import type { FileReadOutcome } from './read-render.ts' import { sessionCwd } from './session-cwd.ts' -/** Default and maximum number of lines returned by one `read` call. */ +/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ export const READ_LIMIT = 2000 -/** Files at or above this size stream; smaller files read whole into memory. */ +/** + * Default streaming threshold (the `readStreamMinSize` config): files at or + * above this size stream; smaller files read whole into memory. + */ export const STREAM_MIN_SIZE = 10 * 1024 * 1024 +/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface ReadToolCaps { + /** Default and maximum number of lines returned by one call. */ + limit: number + /** Maximum characters returned for a single line. */ + maxLineLength: number + /** Maximum bytes returned for selected file lines. */ + maxBytes: number + /** Files at or above this size stream; smaller files read whole into memory. */ + streamMinSize: number +} + /** Validated `read` arguments after defaulting. */ interface ReadInput { filePath: string @@ -42,17 +58,17 @@ function parsePositiveInteger(value: number, name: string): number { return value } -/** Validate value constraints the schema DSL can't express. */ -export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput { +/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */ +export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') - const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit') - if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`) + const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit') + if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`) return { filePath: args.file_path, offset, limit } } /** Register the `read` tool and its system-prompt guidance. */ -export function applyReadTool(ctx: Context): void { +export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.systemPrompt.section({ name: 'tool:read', order: 100, @@ -65,10 +81,10 @@ export function applyReadTool(ctx: Context): void { parameters: { file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' }, offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, - limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` }, + limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, async execute(args, exec): Promise { - const input = parseReadArgs(args) + const input = parseReadArgs(args, caps.limit) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) @@ -82,17 +98,19 @@ export function applyReadTool(ctx: Context): void { // Stream when the file is large OR size is unknown, so a size-less backend // never buffers an arbitrarily large file. - const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE + const chunks = info.size === undefined || info.size >= caps.streamMinSize ? await ctx.fs.streamText(target, exec.signal) : [await ctx.fs.readText(target, exec.signal)] - const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath) + const window = await buildWindow( + chunks, + { offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes }, + target.displayPath, + ) const outcome: FileReadOutcome = { offset: input.offset, - limit: input.limit, lines: window.lines, totalLines: window.totalLines, - version: info.version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } // Record the observed version (a no-op when no policy plugin listens). The @@ -101,19 +119,22 @@ export function applyReadTool(ctx: Context): void { ctx.emit('fs/observed', target, info.version, exec) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, - // Pure display: a UI card titled by the file, `read` kind (icon), and a - // location so an editor can follow along to the file (and the read's offset - // line). `rawInput` surfaces offset/limit when the model narrowed the read. - presentCall(args) { - const detail = [ - ...args.offset !== undefined ? [`offset ${args.offset}`] : [], - ...args.limit !== undefined ? [`limit ${args.limit}`] : [], - ].join(', ') + // 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 { - title: `Read ${args.file_path}`, + card: 'generic', + title: `Read ${args.file_path}${window}`, kind: 'read', - locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }], - ...detail.length > 0 ? { rawInput: detail } : {}, + locations: [{ path: args.file_path, line: offset ?? 1 }], } }, })) diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 97098bd78d..1054e2ff2f 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -13,10 +13,12 @@ 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. */ @@ -50,7 +52,7 @@ export function applyWriteTool(ctx: Context): void { 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 { + 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) @@ -60,14 +62,41 @@ export function applyWriteTool(ctx: Context): void { 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) - return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] + // 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: `edit` kind (an editor treats create/replace as an edit) and - // a location so the UI can follow along to the written file. The create-vs- - // overwrite fact lives in the model-facing result text; `presentResult` only - // sees `{ content, isError }` (not the outcome), so the title stays static. - presentCall(args) { - return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] } + // 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/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index b596a47465..c23ad79170 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,10 +6,11 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' +import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs' -const READ_ALL: ReadWindow = { offset: 1, limit: 2000 } +const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } +const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS } /** Yield `text` as one chunk (whole-file read shape). */ async function* whole(text: string): AsyncIterable { @@ -34,7 +35,7 @@ describe('buildWindow', () => { }) it('applies offset/limit', async () => { - const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f') + const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f') expect(result.lines.map(l => l.number)).toEqual([2, 3]) expect(result.totalLines).toBe(4) }) @@ -62,7 +63,7 @@ describe('buildWindow', () => { }) it('rejects an offset past EOF', async () => { - await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) }) it('flushes a final line with no trailing newline', async () => { @@ -76,9 +77,22 @@ describe('buildWindow', () => { expect(result.totalLines).toBe(2) }) + describe('caps are per-request (the plugin config reaches the window)', () => { + it('truncates lines at a custom maxLineLength and names it in the suffix', async () => { + const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f') + expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)') + }) + + it('caps output at a custom maxBytes', async () => { + const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f') + expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb']) + expect(result.truncatedByBytes).toBe(true) + }) + }) + describe('chunked input (streamed read shape)', () => { it('windows identically when text arrives in small chunks', async () => { - const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f') + const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f') expect(result.lines).toEqual([{ number: 2, text: 'two' }]) expect(result.totalLines).toBe(3) }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 12f373753e..ac99d176d4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -15,6 +15,7 @@ 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, @@ -39,7 +40,7 @@ class FakeFs extends FileSystem { } override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } + return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } } override async stat(target: FsTarget): Promise { this.throwIfArmed() @@ -54,19 +55,23 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } + override async listDir(_target: FsTarget): Promise { + return [] + } override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() this.writeIntents.push(expected) - const existed = this.files.has(target.targetKey) + const before = this.files.get(target.targetKey) ?? null this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } } override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() this.editIntents.push(expected) const content = this.files.get(target.targetKey) ?? '' - this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { version: FsVersion('v3'), before: content, after } } } @@ -247,7 +252,7 @@ describe('read tool', () => { }) describe('formatReadOutput footer variants', () => { - const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') } + const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 } it('reports a byte-capped read', () => { const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) @@ -352,34 +357,209 @@ describe('tool-owned presentation (pure presentCall)', () => { return ctx.tools.get(name)?.presentCall?.(args) } - it('read: titles by file, read kind, location with the offset line', async () => { + 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({ - title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40', + card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', locations: [{ path: 'src/a.ts', line: 12 }], }) }) - it('read: omits rawInput and the location line when offset/limit are unset', async () => { + it('read: bare title and line-1 location when offset/limit are unset', async () => { expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({ - title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }], + card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], }) }) - it('write: titles by file, edit kind, location', async () => { - expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({ - title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }], + 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('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => { - expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({ - title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }], + 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('edit: clips a long old/new string in the rawInput summary', async () => { - const long = 'a'.repeat(60) - const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' }) - expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}…`)} → ${JSON.stringify('b')}`) + 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 index 6af16400c0..f0133b1d2b 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, diff --git a/packages/hooks/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, 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, 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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : 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 | 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, 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() + 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 + /** 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 { + 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 +} 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 { + 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 { + 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): { + 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 { + specs.push(spec) + return run(spec) + }, + } as unknown as BashExecutor + return { bash, specs } +} + +function result(over: Partial = {}): 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 + +/** 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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : 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 = 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 { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => b.type === 'text').map(b => b.text).join('') +} + +function base(agent: Agent | undefined, event: string): Record { + 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 { + return { ...base(agent, 'SessionStart'), source } +} +function promptPayload(agent: Agent, content: ContentBlock[]): Record { + return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) } +} +function preToolPayload(exec: ToolExecution): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + 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 { + 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 { + 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 { + 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[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[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 + +/** 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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : 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 = 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 { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => 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 { + 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 { + 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 { + // `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 { + 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 { + 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 { + 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 + 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 { + 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 { + 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 { + 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/support/ui-stdio/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json similarity index 79% rename from packages/support/ui-stdio/tsconfig.json rename to packages/hooks/hooks-codex/tsconfig.json index e778da89bf..f936b500aa 100644 --- a/packages/support/ui-stdio/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -17,17 +17,23 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../hook-protocol" + }, { "path": "../../core/agent" }, { - "path": "../../core/user-interaction" + "path": "../../core/tools" + }, + { + "path": "../../core/session" }, { "path": "../../llm/llm" }, { - "path": "../../core/session" + "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 - /** 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 { @@ -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 { return raw } -function strictByToolName(tools: ToolSchema[] | undefined): Map { - 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 { - 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 } @@ -22,11 +24,13 @@ afterEach(async () => { async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise { 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 { + 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): AsyncIterable @@ -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 { 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 - 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-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 = 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).journalMode) this.coordinator = new PersistenceCoordinator(this.ctx, this) } - private async openDb(path: string): Promise { + private async openDb(path: string, journalMode: JournalMode): Promise { 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 => { // 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 @@ -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 @@ -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 } 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 | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | +| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. | +| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. | ```yaml - id: subagent-acp diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 037d32889e..cd425e763c 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -21,7 +21,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts' +import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' export const inject = ['subagents'] @@ -52,6 +52,14 @@ export interface Config { * ambient secrets do not leak implicitly. */ env: Record + /** + * 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 = z.object({ @@ -61,8 +69,20 @@ export const Config: z = 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> & Pick + /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects @@ -71,7 +91,7 @@ export const Config: z = z.object({ class AcpProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } - constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {} + constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} start(request: SubagentStartRequest) { const spec: AcpRunSpec = { @@ -80,6 +100,8 @@ class AcpProvider implements SubagentProvider { cwd: this.config.cwd ?? process.cwd(), permission: this.config.permission, env: this.config.env, + disposeEofGraceMs: this.config.disposeEofGraceMs, + disposeGraceMs: this.config.disposeGraceMs, onError: (error, stopReason) => { // The seam forbids `result` rejecting, so a child-level failure is // flattened to a stop reason — preserve it here rather than losing it. @@ -91,5 +113,9 @@ class AcpProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config)) + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) + ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved)) } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 06f7a9ece8..74291b7e65 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -73,16 +73,16 @@ export interface AcpRunSpec { /** * Grace period (ms) for the child's EOF-driven quiesce in * {@link SubagentRun.dispose} — the window to flush persistence and tear down - * its OWN nested subprocesses before the parent escalates to a signal. Defaults - * to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value. + * its OWN nested subprocesses before the parent escalates to a signal. The + * plugin fills this from its `disposeEofGraceMs` config. */ - disposeEofGraceMs?: number + disposeEofGraceMs: number /** * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in - * {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS}; - * a test injects a small value to exercise the escalation without a long wait. + * {@link SubagentRun.dispose}. The plugin fills this from its + * `disposeGraceMs` config. */ - disposeGraceMs?: number + disposeGraceMs: number /** * Sink for a child-level failure that the run flattened into a stop reason * (the seam contract forbids `result` rejecting). The driver calls this with @@ -94,19 +94,20 @@ export interface AcpRunSpec { } /** - * Default grace for the child's EOF-driven quiesce on dispose — the window for it - * to flush persistence and tear down its OWN nested subprocesses (which may run - * their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a - * signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative - * child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a - * bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs - * MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off - * exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, - * so this is a standalone generous default, NOT derived from any child's internals. + * Default grace for the child's EOF-driven quiesce on dispose (the + * `disposeEofGraceMs` config) — the window for it to flush persistence and tear + * down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL` + * escalation) before the parent escalates to a signal. Deliberately LARGER than + * {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself + * waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s + * SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single + * signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it + * reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is + * a standalone generous default, NOT derived from any child's internals. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */ +/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** @@ -372,8 +373,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // Reach quiescence, not merely request it (dispose must AWAIT the child // actually stopping). If the child is already gone, nothing to do. if (child.exitCode !== null || child.signalCode !== null) return - const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS - const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS + const eofGraceMs = spec.disposeEofGraceMs + const graceMs = spec.disposeGraceMs // 1. Graceful: end the ACP request stream (stdin EOF) and let the child // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal // session — it tears down via the server bridge's connection-close path diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 9819320ec5..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 ` — 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/README.md b/packages/subagent/subagent/README.md index 57862ca8ab..3b72971dc1 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -32,6 +32,8 @@ Unlike the bash seam (one executor per context, second load throws), **multiple `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 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..b0514edcac 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, @@ -54,12 +64,14 @@ declare module 'cordis' { * A subagent run started — emitted after the provider is resolved and its * capabilities validated, as the child run begins. Paired with * {@link Events['subagent/end']}. + * @param info - which provider started which child agent. * @mode emit */ 'subagent/start'(info: SubagentRunInfo): void /** * A subagent run settled — emitted when {@link SubagentRun.result} * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * @param info - the run identity plus stop reason and final output. * @mode emit */ 'subagent/end'(info: SubagentRunEndInfo): void @@ -82,6 +94,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[] } /** @@ -111,6 +131,8 @@ export class SubagentService extends Service { * Register a provider under its `provider.name`. Throws {@link SubagentError} * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed * with the calling fiber (HMR-safe). + * @param provider - the provider; its `name` is the registry key. + * @returns the disposer that unregisters the provider. */ registerProvider(provider: SubagentProvider): () => void { const dispose = this.ctx.effect(function* (this: SubagentService) { @@ -127,12 +149,19 @@ export class SubagentService extends Service { return () => void dispose() } - /** Look up a registered provider by name (`undefined` if absent). */ + /** + * Look up a registered provider by name (`undefined` if absent). + * @param name - the provider name as registered. + * @returns the provider, or undefined when the name is unknown. + */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) } - /** The names of all registered providers (insertion order). */ + /** + * The names of all registered providers (insertion order). + * @returns the registered provider names. + */ list(): string[] { return [...this.providers.keys()] } @@ -144,6 +173,9 @@ export class SubagentService extends Service { * for the first unmet one — fail loud, before any child is created), then * delegates to {@link SubagentProvider.start} and emits `subagent/start` / * `subagent/end` around the run. + * @param name - the provider to run on. + * @param request - the child's prompt, capabilities, and options. + * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { const provider = this.providers.get(name) @@ -165,11 +197,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 diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6876c6cd80..3a8807ad0d 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,120 @@ 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, + 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 + 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, + 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 + 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) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index dda4e7c3d0..2f40cd6f8c 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -276,6 +276,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) diff --git a/packages/support/README.md b/packages/support/README.md index dce7485f8c..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, and provides `ctx.userInteraction` answers | (drives `ctx.agents`, registers a user-interaction provider) | | `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/ui-stdio/README.md b/packages/support/ui-stdio/README.md deleted file mode 100644 index f65452ace5..0000000000 --- a/packages/support/ui-stdio/README.md +++ /dev/null @@ -1,48 +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), renders that agent's streamed output and tool activity to stdout, and provides the `ctx.userInteraction` answer provider for `ask_user_question`. A UI is "just a plugin" here — it consumes the `agent/*` event taxonomy plus the `agents` and `userInteraction` services (`inject: ['agents', 'userInteraction']`), so the same plugin drives any example or product surface with the required seam loaded. - -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 `[ 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] …`. - -## User Questions - -When `ctx.userInteraction.ask()` is called, the UI writes the question, renders numbered options when provided, and treats the next stdin line as the answer instead of sending it to the agent. Recommended options render first, option details render from `description`, a numeric line selects the displayed option, an empty line selects the recommended option when one exists, and a non-empty free-form line is accepted when `allowCustom` is not `false`. - -## 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 ead2ba25d8..7565d8b418 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -8,8 +8,9 @@ Integrations that expose the agent to an external editor or client. These are ** | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `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. `tool-ask-user` lives here because it is a model-facing product affordance that depends on a UI/provider seam; it is not part of the providerless core spine. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 3e732a5441..5bee33fdc7 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", @@ -43,6 +44,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 { - 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 { - installFailLoud() - const snapshotMode = process.env.DSH_SNAPSHOT - const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode) - if (snapshotMode !== 'replay') loadEnv() - const ctx = await boot(configPath) - if (snapshotMode !== undefined) { - process.stdin.on('end', () => { - void ctx.fiber.dispose().then(() => { process.exit(0) }) - }) - } -} - -/* v8 ignore start -- top-level CLI invocation; the testable core is - resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */ -await main() /* v8 ignore stop */ diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index e2bab79ee4..d900c14152 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -39,12 +39,10 @@ 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/user-interaction', 'core/agent-loop', 'llm/llm', - 'llm/llm-deepseek', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', - 'support/invariants', + 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', + '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', - 'ui/tool-ask-user', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index 79a68c6337..1d544ae1c0 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 4e4723362e..1a685029cd 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets the bridge provide ACP-backed answers for tools such as `ask_user_question`. +`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. ### Config @@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | | `systemPrompt` | — | Per-agent system prompt. | -| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. | -| `agentVersion` | `0.0.1` | Server version reported in `initialize`. | + +The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config. ## ACP method mapping @@ -28,12 +28,12 @@ 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/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) | -| `elicitation/create` | `ctx.userInteraction` provider | `ask_user_question` pauses the tool call and asks the ACP client for a session-scoped form; choices use a `choice` single-select field, free-form answers use `answer`/`custom_answer`, and cancel/decline returns a structured `UserInteractionError` | +| `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) | +| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` requests to ACP form elicitations; recommended options become defaults, option descriptions are shown in enum titles, optionless requests remain free-form even when `allowCustom` is false | ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — and `ctx.userInteraction` requests, which carry only the calling `Agent` — demux in O(1). Every `session/event`, `agent/status`, and ask-user elicitation is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.) +The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.) Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. @@ -43,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, optional `content` blocks shown alongside, and optional `locations` — `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) 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 `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit ` title, a `read`/`edit` kind, and a `locations` entry for the file. (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 @@ -66,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 046465d30b..6162fae137 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,10 +96,10 @@ 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 | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. | | `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. | @@ -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 tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`). 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/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 ddf5ee6c5c..b9d61205c4 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, @@ -65,12 +65,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' @@ -239,10 +239,6 @@ export interface AcpConfig { model?: string /** Per-agent system prompt. */ systemPrompt?: string - /** Agent/server name reported to the client in `initialize`. */ - agentName?: string - /** Agent/server version reported to the client in `initialize`. */ - agentVersion?: string /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -256,8 +252,6 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ model: Schema.string(), systemPrompt: Schema.string(), - agentName: Schema.string().default('deepseek-harness-acp'), - agentVersion: Schema.string().default('0.0.1'), }) /** @@ -321,22 +315,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 @@ -437,7 +425,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 */ @@ -472,15 +460,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 @@ -584,7 +571,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 @@ -793,7 +782,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 @@ -967,67 +956,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 } : {}, - ...present.locations !== undefined ? { locations: present.locations } : {}, - ...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': { @@ -1070,46 +1005,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[] - /** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */ - locations?: { path: string; line?: number }[] - /** 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 @@ -1119,14 +1028,14 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map() + private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. * @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( @@ -1134,10 +1043,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) { @@ -1145,51 +1054,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.locations !== undefined ? { locations: present.locations } : {}, - ...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 } } @@ -1199,16 +1096,8 @@ export class ToolPresenter { * results pass their raw content through unchanged. */ export const nullToolPresenter: Pick = { - 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. */ @@ -1233,20 +1122,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 `..…`), 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. */ @@ -1256,12 +1246,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 602b355b6f..daf3a3797e 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 () => { @@ -325,14 +325,13 @@ describe('acp bridge', () => { await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined() }) - it('honors agentName/agentVersion/systemPrompt config', async () => { + it('honors systemPrompt config', async () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')], - config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' }, + config: { systemPrompt: 'be terse' }, }) - const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) // Create + prompt so the systemPrompt config flows through agentOptions and // reaches the model request. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) diff --git a/packages/ui/acp/tests/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 a7887ca5ad..586f8e2a62 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -166,7 +166,7 @@ export async function makeBridgeHarness(options: { * 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 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 9e1578c8c1..311a853acd 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) - it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => { + it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'bash', - kind: 'execute', + // The fallback never sniffs a kind from the tool name — even a name a + // first-party tool uses (`bash`) renders `other`; kinds are tool-owned + // via presentCall. + kind: 'other', status: 'in_progress', rawInput: { command: 'ls' }, }]) }) - it('infers tool kinds: read*/write*/edit*/other', () => { - const kind = (name: string): unknown => - updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0] - expect((kind('read_file') as { kind: string }).kind).toBe('read') - expect((kind('write') as { kind: string }).kind).toBe('edit') - expect((kind('edit_file') as { kind: string }).kind).toBe('edit') - expect((kind('frobnicate') as { kind: string }).kind).toBe('other') - }) - it('falls back to the raw argument string when tool arguments are not JSON', () => { const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0] expect((update as { rawInput: unknown }).rawInput).toBe('not json') }) + it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => { + // `JSON.parse('')` throws, so without the empty-string guard a zero-arg + // call would render `rawInput: ''` via the non-JSON fallback; the guard + // normalizes it to `{}`. + const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0] + expect((update as { rawInput: unknown }).rawInput).toEqual({}) + }) + it('maps tool/result to completed/failed tool_call_update with text content', () => { const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false })) expect(ok).toEqual([{ @@ -91,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([]) @@ -163,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', @@ -171,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}` }], }), } @@ -247,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( @@ -286,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 = { @@ -336,11 +339,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] }) }) - it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => { + 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>, + } + 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>, + } + 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 — - // including `locations` for editor follow-along. (AGENTS.md "prefer the real - // implementation over a mock".) + // 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) @@ -352,44 +394,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => 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', kind: 'read', - rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }], + 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' }) }) @@ -403,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: [ @@ -422,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', @@ -458,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>, + }) + 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 { + 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 { + 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() }) }) 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 { + 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 1cde841449..554311e85b 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -1,7 +1,5 @@ # @deepseek-ai/dsh-stdio-agent -Terminal stdio chat app. It composes the agent-core spine with JSONL persistence, the readline stdio UI, the user-interaction seam, and the `ask_user_question` tool so the demo/coding front door can pause for human confirmation. - The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. @@ -17,9 +15,9 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent, and the user-interaction provider | +| `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. @@ -35,12 +33,12 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte ## The bin -`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config 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: diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index caf56210cc..030a9c1307 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -32,13 +32,14 @@ "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-tool-ask-user": "^0.0.1", - "@deepseek-ai/dsh-ui-stdio": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" @@ -46,13 +47,14 @@ "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-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-ui-stdio": "workspace:^", "@deepseek-ai/dsh-user-interaction": "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 { - 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 { - 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 d3aecd39d8..c9aae63f04 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 */ @@ -44,7 +47,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiStdio from '@deepseek-ai/dsh-ui-stdio' +import * as uiStdio from './stdio-chat.ts' export const name = 'stdio-agent' @@ -83,7 +86,7 @@ export const Config: z = z.object({ * Compose the spine with the stdio front door. The console logger comes first * (infra), then the agent-core bundle pre-creating the `main` agent from this * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then - * the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf + * the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf * concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { diff --git a/packages/support/ui-stdio/src/index.ts b/packages/ui/stdio-agent/src/stdio-chat.ts similarity index 79% rename from packages/support/ui-stdio/src/index.ts rename to packages/ui/stdio-agent/src/stdio-chat.ts index fc1289281c..9a076d04f5 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' @@ -103,38 +100,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() + 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('agent/error', (agent, turn, step, error) => { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write(`\n[${agent.id} turn ${turn} step ${step} error] ${error.message}\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 b28bf382ad..cda8b41b1f 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -34,9 +34,8 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') // to the built `lib/` (package.json `main`), exactly as an installed dep would. const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', - 'core/tools', 'core/user-interaction', 'ui/tool-ask-user', - 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'support/invariants', 'support/ui-stdio', + 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', + '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 2b7e115026..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,7 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, + // 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 } @@ -30,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 c21b1c8812..033b0a35ba 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -12,9 +12,11 @@ import * as stdioAgent from '../src/index.ts' * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends. * * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev - * plugin the in-process tier cannot import); the REAL Loader-path guard (export - * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless - * echo smoke in `examples/echo-agent`. Here we assert the composition + config + * plugin the in-process tier cannot import); the keyless echo smoke in + * `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots + * through the real Loader, while the export SHAPE is pinned by this suite's + * explicit `unwrapExports` assertion (an inject-less app would boot past a + * stray default rather than crash). Here we assert the composition + config * forwarding the unit tier can reach. */ async function mount(config: stdioAgent.Config): Promise { diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts similarity index 83% rename from packages/support/ui-stdio/tests/ui-stdio.spec.ts rename to packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 8fc3613490..0c636fcae9 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -3,10 +3,10 @@ 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 UserInteractionService from '@deepseek-ai/dsh-user-interaction' -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 @@ -57,11 +57,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 = {}) { @@ -96,43 +109,93 @@ 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) + await ctx.plugin(UserInteractionService) + 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', 'userInteraction'] })) + 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 () => { @@ -173,8 +236,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' }] }, @@ -184,9 +246,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: '{}' }, @@ -198,29 +259,11 @@ 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) }) - - it('renders agent errors so failed model requests are visible in stdio', async () => { - const { ctx, out } = await setup() - const agent = makeAgent('main') - - ctx.emit('agent/error', agent, 1, 1, new Error('fetch failed')) - - expect(out.text()).toContain('\n[main turn 1 step 1 error] fetch failed\n> ') - }) - - it('resets dim styling when an agent error 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: 'thinking' }) - ctx.emit('agent/error', agent, 1, 1, new Error('fetch failed')) - - expect(out.text()).toContain('\x1B[2mthinking\x1B[0m\n[main turn 1 step 1 error] fetch failed') - }) }) describe('createStdioChat input', () => { diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 9cd7f8d998..264b1cac4c 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" }, @@ -37,9 +40,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 { + 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 = { + 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(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/noscript>/gi, '') + .replace(//g, '') + + // Convert links to markdown before stripping tags. + text = text.replace(/]*\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(/]*>([\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(/]*>([\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(//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 = 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 + +/** 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 { + 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> + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('

Hello

World

') } + server = createServer((req, res) => { handler(req, res) }) + await new Promise(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(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 { + 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 + 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[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..2347f6a8a8 --- /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[1] + search?: WebSearchProvider + fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider +} = {}): Promise<{ ctx: Context; fiber: Awaited>; 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: '

Title

Body text

' }, + }) + 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: '

y

' })).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('

Tom & Jerry

link') + 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('

a'b

')).toBe("a'b") + expect(htmlToMarkdown('
x
\n\n\n
y
')).toBe('x\n\ny') + }) + + it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => { + expect(htmlToMarkdown('

AB

')).toBe('AB') + expect(htmlToMarkdown('

© —

')).toBe('© —') + expect(htmlToMarkdown('

¬areal;

')).toBe('¬areal;') + // An out-of-range code point keeps the original entity text (fromCodePoint fallback). + expect(htmlToMarkdown('

')).toBe('�') + expect(htmlToMarkdown('

')).toBe('�') + }) + + it('renders a link with an empty label as its bare href', () => { + expect(htmlToMarkdown('')).toBe('https://a.test') + }) + + it('converts headings and list items to markdown', () => { + expect(htmlToMarkdown('

Heading

after

')).toContain('## Heading') + const list = htmlToMarkdown('
  • one
  • two
') + 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 => (typeof s.text === 'function' ? s.text() : 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 = 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 + +/** 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 { + 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 { + 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 { + 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 { + 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(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(resolve => server.close(() => { resolve() })) +}) + +function provider(overrides: Partial = {}): 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('

hi

') } + const result = await provider().fetch({ url: base }) + expect(result.body).toEqual({ kind: 'html', content: '

hi

' }) + }) + + 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; 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 = 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 { + const map = new Map() + 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() + 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 { + 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..ae4dc3bf6a --- /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 coding agent?', 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 + 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 + 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[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)['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 = 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 { + // 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..84c0214228 --- /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 coding agent', 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)['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 = 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 { + 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..02aaa914e6 --- /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 coding agent?', 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)['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 59% rename from packages/support/ui-stdio/package.json rename to packages/web/web/package.json index aa4edbc37e..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,19 +22,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "@deepseek-ai/dsh-llm": "^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-user-interaction": "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` 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

{ + /** The configured provider id for this capability, if any. */ + readonly configuredId?: string + /** Providers registered for this capability kind. */ + readonly providers: ReadonlyMap +} + +/** + * 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 = z.object({ + searchProvider: z.string(), + fetchProvider: z.string(), + }) + + private searchProviders = new Map() + private fetchProviders = new Map() + 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

(store: Map, 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; 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 { + 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 { + 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

(selection: Selection

): 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 +} + +/** + * 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 +} + +/** + * 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, +): 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 { + 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[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/web/web/tsconfig.json b/packages/web/web/tsconfig.json new file mode 100644 index 0000000000..e9de391ba1 --- /dev/null +++ b/packages/web/web/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": "../../llm/llm" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1caf50248..ca0b59dfb8 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: @@ -331,6 +340,13 @@ importers: 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:^ @@ -366,6 +382,95 @@ 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/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': @@ -693,28 +798,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 - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../core/user-interaction - 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': @@ -817,6 +900,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 @@ -833,6 +919,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': @@ -850,6 +948,12 @@ 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 @@ -859,9 +963,6 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../tool-ask-user - '@deepseek-ai/dsh-ui-stdio': - specifier: workspace:^ - version: link:../../support/ui-stdio '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../core/user-interaction @@ -899,6 +1000,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': @@ -1023,6 +1223,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 @@ -1032,6 +1235,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'} @@ -1183,6 +1401,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: @@ -1199,6 +1427,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'} @@ -1408,6 +1672,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'} @@ -1437,6 +1710,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==} @@ -1450,6 +1729,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==} @@ -1983,6 +2265,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==} @@ -1995,6 +2370,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==} @@ -2016,6 +2397,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==} @@ -2078,6 +2465,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: @@ -2161,6 +2551,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==} @@ -2195,6 +2588,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==} @@ -2210,6 +2611,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==} @@ -2217,10 +2624,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'} @@ -2230,6 +2804,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==} @@ -2239,6 +2816,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'} @@ -2250,6 +2830,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} @@ -2266,9 +2853,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'} @@ -2437,6 +3031,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'} @@ -2444,6 +3041,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==} @@ -2455,6 +3056,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'} @@ -2463,6 +3068,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} @@ -2471,6 +3079,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'} @@ -2479,6 +3094,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==} @@ -2508,6 +3126,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'} @@ -2535,14 +3162,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] @@ -2679,12 +3319,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==} @@ -2698,6 +3345,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==} @@ -2731,6 +3383,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==} @@ -2885,9 +3543,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'} @@ -2910,6 +3574,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} @@ -2941,6 +3611,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==} @@ -2948,6 +3622,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} @@ -2977,6 +3654,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'} @@ -2984,6 +3667,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==} @@ -3024,6 +3714,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'} @@ -3032,6 +3725,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==} @@ -3047,6 +3743,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 @@ -3060,6 +3771,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} @@ -3142,6 +3857,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==} @@ -3157,6 +3876,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: @@ -3246,6 +3969,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} @@ -3254,6 +3981,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'} @@ -3280,10 +4019,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'} @@ -3310,12 +4056,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 @@ -3584,6 +4355,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) @@ -3601,6 +4380,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) @@ -3761,6 +4564,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 @@ -3788,6 +4593,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 @@ -3802,6 +4615,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 @@ -4160,6 +4977,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 @@ -4170,6 +5104,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': {} @@ -4188,6 +5131,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)': @@ -4281,6 +5229,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 @@ -4293,7 +5246,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: @@ -4375,6 +5328,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: {} @@ -4399,6 +5356,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): @@ -4417,6 +5378,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: @@ -4425,12 +5394,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 @@ -4439,6 +5608,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: {} @@ -4447,6 +5620,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 @@ -4457,8 +5636,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 @@ -4679,10 +5862,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: @@ -4699,20 +5890,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: {} @@ -4738,6 +5941,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: @@ -4766,10 +5995,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) @@ -4786,6 +6021,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 @@ -4887,10 +6126,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 @@ -4907,6 +6150,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 @@ -5009,6 +6254,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 @@ -5298,8 +6569,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: {} @@ -5312,6 +6589,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 @@ -5349,10 +6633,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 @@ -5411,12 +6699,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 @@ -5446,12 +6749,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: {} @@ -5463,6 +6770,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: {} @@ -5471,6 +6792,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 @@ -5538,6 +6861,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.28.0: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -5561,6 +6886,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 @@ -5586,7 +6913,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)) @@ -5611,13 +6938,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 @@ -5631,8 +6975,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//, no deeper nesting`) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json new file mode 100644 index 0000000000..26db66b995 --- /dev/null +++ b/scripts/doc-budgets.manifest.json @@ -0,0 +1,10 @@ +{ + "AGENTS.md": 1575, + "docs/AGENTS.md": 1315, + "docs/architecture.md": 1890, + "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 8d84eff57a..74d4f6e00b 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.` 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.` 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` 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 = { Agent: 'core.md', @@ -144,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 @@ -154,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[] = [] @@ -175,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 @@ -194,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; returns: string | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const params = new Map() + 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) { @@ -212,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') @@ -229,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') @@ -281,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 @@ -297,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` 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)$/.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)) } @@ -364,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) @@ -376,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, '```', '') @@ -387,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 = [ + '', + '', +] + +/** 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[] = [ - '', + ...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.` 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.` 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](../architecture.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.` 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.` 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]}))`) } @@ -439,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..141b8c48d8 --- /dev/null +++ b/scripts/gen-doc-graphs.ts @@ -0,0 +1,785 @@ +/** + * 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 +} + +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> + listeners: Set +} + +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-ask-user', '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: 'userInteraction', + pkg: 'user-interaction', + title: 'Human question/answer seam', + mode: 'seam', + implementations: ['stdio-agent', 'acp'], + consumers: ['tool-ask-user', 'stdio-agent', 'acp'], + note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + }, + { + 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 [ + '', + '', + `# ${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() + 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 `${value.replace(/&/g, '&').replace(//g, '>')}` +} + +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 { + 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, '
') +} + +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() + const edges = new Set() + const companionEdges = new Set() + 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}
${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
console logger
pre-created main agent"]`) + } else if (pluginName === '@deepseek-ai/dsh-acp-agent') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
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)}
cordis.yml"]`, + ) + for (const plugin of plugins) { + const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) + lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${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 { + const out = new Map() + const ensure = (event: string): EventRelation => { + const existing = out.get(event) + if (existing) return existing + const next = { dispatchers: new Map>(), listeners: new Set() } + 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() + 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() + 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, 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>, pkgsByShort: Map): 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, pkgsByShort: Map): 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>(), listeners: new Set() } + 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')}
logged before execution"]`, + ' presentCall["UI pending card
presentCall(args)"]', + ` pre["${mermaidCode('tools/pre-execute')} waterfall
hooks, permission, sandbox"]`, + ' denied["deny or ask
tool body skipped"]', + ' toolBody["Registered tool execute() body"]', + ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, + ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}"]`, + ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, + ' context["Buffered additionalContext
context/message after all tool results"]', + ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, + ' presentResult["UI completed card
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: 'docs/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 = { + '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', + 'docs/acp/snapshot-replay.md': 'ACP snapshot replay', + } + const modes: Record = { + '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', + 'docs/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//` 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: 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 [ '', '', '# 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', + '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 = { + 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///…` 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() + 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': .`) + 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() + 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[] = [ + '', + '', + '# 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..ad7aeb6588 --- /dev/null +++ b/scripts/gen-rfc-index.ts @@ -0,0 +1,30 @@ +/** + * Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree + * (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering + * rules). Rewrites ONLY the marker-delimited regions; the curated prose is + * untouched. Freshness is asserted by `verify-rfc-classification.ts` (a + * `doc-sync` member), so a stale committed index fails CI. + * + * Run: `pnpm run gen-rfc-index`. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts' + +const { rfcs, errors } = walkRfcTree() +if (errors.length > 0) { + console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:') + for (const e of errors) console.error(` ${e}`) + process.exit(1) +} + +const readmePath = resolve(rfcRoot, 'README.md') +const readme = readFileSync(readmePath, 'utf8') +const next = spliceReadme(readme, rfcs) +if (next === readme) { + console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`) +} else { + writeFileSync(readmePath, next) + console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`) +} diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 55d897c18e..06df3ac0f4 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -42,6 +42,9 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +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 ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' @@ -49,6 +52,7 @@ 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' @@ -73,6 +77,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 @@ -96,24 +106,34 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-ask-user', dir: 'tool-ask-user', source: 'packages/ui/tool-ask-user/src/index.ts', + requires: ['ctx.tools', 'ctx.userInteraction'], + writes: ['tool/call', 'tool/result after a UI/provider answers the question'], async mount(ctx) { await ctx.plugin(UserInteractionService) await ctx.plugin(ToolAskUser) }, + note: + 'ask_user_question pauses the tool call until the active UI provider returns a human answer.', }, { 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, @@ -128,6 +148,9 @@ const TOOL_PACKAGES: ToolPackage[] = [ 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. @@ -141,9 +164,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.', }, ] @@ -151,6 +197,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 @@ -200,7 +249,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() } @@ -212,12 +269,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, '
') : '-' +} + /** Render the full catalog (pure, deterministic given the manifest-ordered input). */ export function render(catalog: ToolCatalog): string { const lines: string[] = [ @@ -226,12 +290,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// @@ -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..e14eea5c59 --- /dev/null +++ b/scripts/rfc-index.ts @@ -0,0 +1,173 @@ +/** + * Shared source of truth for the RFC index: the tree walker (structure rules) + * and the README table renderer. `gen-rfc-index.ts` writes the generated + * regions; `verify-rfc-classification.ts` checks structure and asserts the + * committed regions are fresh. Pure module — no side effects on import. + * + * The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)): + * every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the + * folder IS the label, and both sets are CLOSED — extending either means + * amending this module AND the README's Classification prose. + * + * The README's per-lifecycle tables are GENERATED between marker comments + * (`` … `end`): section headings and + * rows are derived from each RFC's path (lifecycle/class), H1 (title, with an + * optional `RFC: ` prefix stripped), and filename date, sorted by date then + * filename. Prose outside the markers is curated by hand and never touched. + */ + +import { readFileSync, 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: \` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`) + continue + } + rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) }) + } + } + return { rfcs, errors } +} + +/** The begin/end marker lines that delimit one lifecycle's generated region. */ +const markers = (lifecycle: string): { begin: string; end: string } => ({ + begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`, + end: `<!-- gen-rfc-index:end ${lifecycle} -->`, +}) + +/** + * Render one lifecycle's generated region body: a `### {Class}` heading plus a + * `| Title | First proposed |` table for every non-empty class, in CLASSES + * order, rows sorted by date then filename. + */ +function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { + const sections: string[] = [] + for (const cls of CLASSES) { + const rows = rfcs + .filter(r => r.lifecycle === lifecycle && r.cls === cls) + .sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base)) + if (rows.length === 0) continue + const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n') + sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`) + } + return sections.join('\n\n') +} + +/** + * Splice freshly rendered regions into the README text. Throws when a marker + * pair is missing, duplicated, or out of order, when a region does not sit + * under its own `## {Lifecycle}` heading, or when an index-shaped table row + * (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions — + * the markers are part of the curated prose, the heading above each region is + * the one its lifecycle names, and index rows live only inside the regions + * (prose links to RFCs remain fine anywhere). + */ +export function spliceReadme(readme: string, rfcs: Rfc[]): string { + let out = readme + const regions: Array<{ from: number; to: number }> = [] + for (const lifecycle of LIFECYCLES) { + const { begin, end } = markers(lifecycle) + const beginAt = out.indexOf(begin) + const endAt = out.indexOf(end) + if (beginAt === -1 || endAt === -1 || endAt < beginAt) { + throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`) + } + if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) { + throw new Error(`README.md has a duplicated ${lifecycle} index marker`) + } + // The region must sit directly under its own lifecycle heading: the last + // H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading + // itself has drifted while the generated table stayed put. + const before = out.slice(0, beginAt) + const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1] + if (lastH2 !== heading(lifecycle)) { + throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? '<none>'}")`) + } + out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}` + regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length }) + } + // Index rows are generated state: a table row linking into a lifecycle + // folder anywhere OUTSIDE the regions is a hand-added index entry the + // generator would never reconcile. + let offset = 0 + for (const line of out.split('\n')) { + const inRegion = regions.some(r => offset >= r.from && offset < r.to) + if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) { + throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`) + } + offset += line.length + 1 + } + return out +} 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 b1256b4bdd..ed35b0f353 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/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/core/user-interaction/src/index.ts" }, { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/core/user-interaction/src/index.ts" }, @@ -52,6 +59,7 @@ { "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" }, @@ -67,6 +75,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-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..b6f1ed2eb5 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,155 +1,50 @@ /** * Doc-sync gate: enforce the RFC classification scheme - * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)). + * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) + * and the freshness of the generated index tables + * ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)). * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the * folder IS the label. This gate is the machine source of truth for the closed * class set and keeps the README index honest. * - * Two checks: + * Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker + * and renderer): * * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder - * from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a - * lifecycle root (other than the README/AGENTS allowlist) fails; an unknown - * class folder fails; a stray file at an unexpected depth fails. This is what - * makes the set CLOSED: a new class folder can't appear without amending - * CLASSES here (and the README's Classification section, per the RFC). + * from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1. + * A loose `.md` directly under a lifecycle root (other than the + * README/AGENTS allowlist) fails; an unknown class folder fails; a stray + * file at an unexpected depth fails. This is what makes the set CLOSED: a + * new class folder can't appear without amending CLASSES (and the README's + * Classification section, per the RFC). * - * 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the - * `### {Class}` heading inside the `## {Lifecycle}` section that matches the - * file's path. A missing entry, a duplicate, or an entry under the wrong - * heading fails. This mirrors `verify-event-taxonomy`: a curated doc table - * checked against the on-disk source of truth, so the index can't drift. - * - * The class DESCRIPTIONS in the README prose are not checked (they are - * explanatory text); only the per-class index tables are. This is checker, not - * fixer: it reports and never rewrites. + * 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md` + * byte-match a fresh render from the tree, so every RFC is listed exactly + * once, under the heading matching its path, with its H1 title and filename + * date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand + * edit. This is checker, not fixer: it reports and never rewrites. * * Run: `tsx scripts/verify-rfc-classification.ts`. */ import { readFileSync } from 'node:fs' -import { relative, resolve } from 'node:path' -import { glob } from 'node:fs/promises' +import { resolve } from 'node:path' +import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts' -const root = resolve(import.meta.dirname, '..') -const rfcRoot = resolve(root, 'docs/rfc') +const { rfcs, errors } = walkRfcTree() -/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ -const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const - -/** - * The closed set of RFC classes (nested folder under each lifecycle). Adding a - * class is a deliberate act: extend this list AND the README's Classification - * section. The gate rejects any folder not listed here. - */ -const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const - -/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */ -const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md']) - -/** Title-case a class/lifecycle folder name for README heading comparison. */ -const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) - -const errors: string[] = [] - -// --- Check 1: structure ----------------------------------------------------- -// Every Markdown file anywhere under a lifecycle folder, at any depth. -interface Rfc { - lifecycle: string - cls: string - base: string - /** Path relative to docs/rfc, for the README link check. */ - rel: string -} -const rfcs: Rfc[] = [] - -for (const lifecycle of LIFECYCLES) { - for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) { - const segs = match.split('/') - // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). - if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue - const cls = segs[1] - const base = segs[2] - if (segs.length !== 3 || cls === undefined || base === undefined) { - errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`) - continue - } - if (!(CLASSES as readonly string[]).includes(cls)) { - errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`) - continue - } - if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) { - errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`) - continue - } - rfcs.push({ lifecycle, cls, base, rel: match }) - } -} - -// --- Check 2: README completeness ------------------------------------------- -// Parse the index into (lifecycle, class) -> set of linked rel paths, by -// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading -// every `](path)` link target underneath. A link target is normalized to its -// path relative to docs/rfc. const readmePath = resolve(rfcRoot, 'README.md') const readme = readFileSync(readmePath, 'utf8') -const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l])) -const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c])) - -/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */ -const listed = new Map<string, Set<string>>() -let curLifecycle: string | null = null -let curClass: string | null = null - -for (const line of readme.split('\n')) { - const h2 = /^##\s+(.+?)\s*$/.exec(line) - if (h2?.[1] !== undefined) { - curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null - curClass = null - continue - } - const h3 = /^###\s+(.+?)\s*$/.exec(line) - if (h3?.[1] !== undefined) { - curClass = classByHeading.get(h3[1].trim()) ?? null - continue - } - if (!curLifecycle || !curClass) continue - // Collect every relative .md link target on this line. - for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) { - const target = m[1] - if (target === undefined) continue - // README links are relative to docs/rfc; normalize and key by location. - const rel = relative(rfcRoot, resolve(rfcRoot, target)) - const key = `${curLifecycle}/${curClass}` - const set = listed.get(key) ?? new Set<string>() - set.add(rel) - listed.set(key, set) - } -} - -// Every on-disk RFC must be listed under the heading matching its path. -const seenOnDisk = new Set<string>() -for (const rfc of rfcs) { - seenOnDisk.add(rfc.rel) - const key = `${rfc.lifecycle}/${rfc.cls}` - if (!listed.get(key)?.has(rfc.rel)) { - errors.push( - `index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`, - ) - } -} - -// Every README entry must point at a real RFC under that same heading (catches a -// misfiled or stale row). -for (const [key, targets] of listed) { - for (const rel of targets) { - if (!seenOnDisk.has(rel)) { - errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`) +if (errors.length === 0) { + try { + if (spliceReadme(readme, rfcs) !== readme) { + errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result') } + } catch (error) { + errors.push(`index: ${error instanceof Error ? error.message : String(error)}`) } } -// --- Report ----------------------------------------------------------------- if (errors.length === 0) { console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`) process.exit(0) 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 e9e1112823..40e4dbe728 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,7 +46,9 @@ "./packages/fs/*/src", "./packages/compact/*/src", "./packages/subagent/*/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 16fafd445f..fa84a23a5a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,11 +34,17 @@ { "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" }, @@ -47,6 +53,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 dfa14b9b50..9d9d97bed1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,11 +45,17 @@ { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "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" }, @@ -58,6 +64,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,