From f5774a2a269322a73fad447f19b361227c28e322 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 4 Jul 2026 05:41:18 -0700 Subject: [PATCH 01/31] =?UTF-8?q?feat(i18n):=20new=20documents=20merge=20b?= =?UTF-8?q?ilingual=20=E2=80=94=20the=20requiredSince=20date=20frontier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs must not enlarge the translation backlog: any date-named document (yyyy-mm-dd-*.md, i.e. an RFC) dated on/after the manifest's requiredSince cutoff must merge with its complete pair, independent of the required back-catalog list. Cutoff ships at 2026-07-05, so the existing 2026-07-04 RFC wave is grandfathered into the batch backlog and everything after is bilingual from birth. The date comes from the filename, deliberately: it is deterministic from tree content alone — no git history (shallow CI checkouts hold), no PR base ref — matching the gate's pure-content design. A proposal dated before the cutoff that merges later escapes the rule; that is the graceful grandfathering of in-flight work, not a hole. --list marks such files (required by date); contract and RFC updated in both languages and the pairs re-recorded. --- docs/i18n/README.i18n.yaml | 4 +-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +-- ...6-07-02-bilingual-docs-and-pairing-gate.md | 2 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- scripts/translation-pairing.manifest.json | 1 + scripts/verify-translation-pairing.ts | 28 +++++++++++++++++-- 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 7a2407ed36..8fc169bdaa 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 +README.md: ba3431c72d9f2a86220df4ed682620514401291f +README.zh.md: 02957c97937525a1da27242620a8327b863de040 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 6e2bbd27c3..ba3431c72d 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -43,7 +43,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co - `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. +**Rollout**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. For the back-catalog, 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 diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 04c99ae336..02957c9793 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -43,7 +43,7 @@ - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md`——术语表本身即是双语构造。 -**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 +**推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 ## 分工 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 index bc9a1cd466..1ef023ec50 100644 --- 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 @@ -2,5 +2,5 @@ # 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 +2026-07-02-bilingual-docs-and-pairing-gate.md: 764ad5a9345c2a138b56e9cedb54848a5f7d6054 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f039fbe3fa44cb379905bbea9a4692af19d71230 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 index 517a6371ec..764ad5a934 100644 --- 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 @@ -32,5 +32,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv - 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. +- 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. New documents are the exception — a date-named document dated on/after the manifest's `requiredSince` cutoff merges bilingual or not at all, so the backlog only ever shrinks. - 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 index f8f68bf5d4..f039fbe3fa 100644 --- 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 @@ -32,5 +32,5 @@ - 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对一致」可以从 yaml 的 git blame 直接回答。 - 两侧说法冲突时,没有机械规则裁决谁赢——由 PR 评审裁决。这是同权的代价,是有意接受的:另一个选项(正典语言)禁止中文先行撰写。 - 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让它们的生成器在输出英文的同时输出中文,届时移出排除清单。 -- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(`--list`),不是红的 CI,因此配对按可评审的批次落地,无需一个巨型 PR。 +- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(`--list`),不是红的 CI,因此配对按可评审的批次落地,无需一个巨型 PR。新文档是例外——文件名日期在 manifest `requiredSince` 当天或之后的文档,要么连同配对一起合入,要么不合入,因此 backlog 只会缩小。 - 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),所以这套机制从不强迫整篇重译。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 8c2708d48d..92e6eef950 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,4 +1,5 @@ { + "requiredSince": "2026-07-05", "required": [ "README.md", "docs/development.md", diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index eea30e4b22..3e9043b39d 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -21,7 +21,10 @@ * 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 + * 3. Date-named documents (`yyyy-mm-dd-*.md`, i.e. RFCs) dated on/after the + * manifest's `requiredSince` merge bilingual — the frontier for NEW + * documents, independent of the `required` back-catalog list. + * 4. `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 @@ -63,6 +66,8 @@ const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/* interface Manifest { required: string[] excluded: string[] + /** Date-named documents (yyyy-mm-dd-*.md, i.e. RFCs) dated on/after this day must merge bilingual. */ + requiredSince: string } const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest @@ -250,7 +255,22 @@ for (const req of manifest.required) { } } -// 2. Every pair that exists at all is complete and consistent. Anchor on the +// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge +// bilingual: a new RFC lands with its pair or not at all. Deterministic from +// the filename alone — no git history, so it holds on shallow CI checkouts. +const DATED = /(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ +for (const source of sources) { + if (isExcluded(source)) continue + const dated = DATED.exec(source) + if (!dated?.[1] || dated[1] < manifest.requiredSince) continue + const { zh } = pairPaths(source) + if (!existsSync(join(root, zh))) { + errors.push(`${source}: dated ${dated[1]} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`) + state.set(source, 'missing') + } +} + +// 3. 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() @@ -317,7 +337,9 @@ if (listMode) { 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 date = DATED.exec(file)?.[1] + const tag = required ? ' (required)' : date && date >= manifest.requiredSince ? ' (required by date)' : ' (backlog)' + console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`) } const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } for (const status of state.values()) counts[status]++ From 0bccb483a5b85ff668f5536fdbc9505b8a0b5bff Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 4 Jul 2026 05:54:22 -0700 Subject: [PATCH 02/31] docs(i18n): state the requiredSince grandfather semantics explicitly Files dated before the cutoff are the grandfathered backlog by definition (including cutoff-eve creations); backdating is a review-visible RFC-convention violation, not a loophole. --- docs/i18n/README.i18n.yaml | 4 ++-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 8fc169bdaa..cb4e049728 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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: ba3431c72d9f2a86220df4ed682620514401291f -README.zh.md: 02957c97937525a1da27242620a8327b863de040 +README.md: bbf292893fc1ca634f3437a8ea0bae855c5712cb +README.zh.md: 29f7408d1e0511ebf5a708507e0ea93aeb7e9ff3 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index ba3431c72d..bbf292893f 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -43,7 +43,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co - `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**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. For the back-catalog, 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. +**Rollout**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. Files dated before the cutoff are the grandfathered backlog by definition — including files created on the cutoff's eve — and a document's filename date is its first-proposed date per the RFC convention, so backdating past the cutoff is a review-visible violation, not a loophole. For the back-catalog, 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 diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 02957c9793..29f7408d1e 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -43,7 +43,7 @@ - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md`——术语表本身即是双语构造。 -**推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 +**推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。日期早于 cutoff 的文件按定义属于被豁免的存量——包括 cutoff 前夜创建的文件——而文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过 cutoff 是评审可见的违规,不是漏洞。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 ## 分工 From abb145d99f74ac3635f1c00c89b02eb57149d42b Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:03:14 -0700 Subject: [PATCH 03/31] =?UTF-8?q?docs(i18n):=20translation=20capability=20?= =?UTF-8?q?=E2=80=94=20style=20samples,=20voice=20rules,=20two-pass=20skil?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human-annotated gold pairs land as docs/i18n/style-samples.md (bilingual by construction, excluded from pairing) and outrank prose tone rules. translation-rules gains a Voice section; shape stays gate-owned so the writer optimizes for natural Chinese. The skill's translate step becomes two passes: native-author writing, then clause-level fidelity check. Terminology rulings: hash -> 哈希 (blob 哈希/提交哈希), HMR and fiber confirmed as tabled. --- .agents/skills/dsh-translate-docs/SKILL.md | 4 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- docs/i18n/style-samples.md | 61 ++++++++++++++++++++++ docs/i18n/terminology.md | 4 +- docs/i18n/translation-rules.i18n.yaml | 4 +- docs/i18n/translation-rules.md | 11 +++- docs/i18n/translation-rules.zh.md | 11 +++- scripts/translation-pairing.manifest.json | 3 +- 10 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 docs/i18n/style-samples.md diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index cde9f00b0e..8a2b231e8f 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -37,7 +37,9 @@ Do not process every file the same way: ## 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. +- **Pass 1 — write, don't transpose.** You are a native technical author of the target language. Read a semantic unit of the source (a paragraph or a tight group), close it, and state its content the way [docs/i18n/style-samples.md](../../../docs/i18n/style-samples.md) does — match the nearest genre sample's register. Shape is the gate's job, not yours: never trade natural phrasing for sentence-by-sentence correspondence. +- **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it. +- 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`. diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index cb4e049728..84cc3e7977 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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: bbf292893fc1ca634f3437a8ea0bae855c5712cb -README.zh.md: 29f7408d1e0511ebf5a708507e0ea93aeb7e9ff3 +README.md: f28676b7d555aca30d8b5b21eba9fb4222949763 +README.zh.md: 5353a8ba04288cf72defa30b5896731b3d419b69 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index bbf292893f..f28676b7d5 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -41,7 +41,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co - `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. +- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. **Rollout**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. Files dated before the cutoff are the grandfathered backlog by definition — including files created on the cutoff's eve — and a document's filename date is its first-proposed date per the RFC convention, so backdating past the cutoff is a review-visible violation, not a loophole. For the back-catalog, 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. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 29f7408d1e..5353a8ba04 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -41,7 +41,7 @@ - `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 -- `docs/i18n/terminology.md`——术语表本身即是双语构造。 +- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。 **推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。日期早于 cutoff 的文件按定义属于被豁免的存量——包括 cutoff 前夜创建的文件——而文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过 cutoff 是评审可见的违规,不是漏洞。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md new file mode 100644 index 0000000000..d5d920c0d8 --- /dev/null +++ b/docs/i18n/style-samples.md @@ -0,0 +1,61 @@ +# 翻译语体样例(style samples) + +本文件是翻译语体的校准锚点:每组样例是一段英文原文与一段人工定稿的中文译文,覆盖本仓库文档的主要文体。**译文的语体以这些样例为准**——它们的效力高于任何对语气的文字描述。翻译或评审时对照最接近的文体样例;样例与规则冲突时,样例胜出。本文件中英对照、自成双语,不参与配对(见 [README.md](README.md) 排除清单)。 + +维护方式:人工评审校准出新的金标段落后追加到对应文体;样例只增不改,改动需评审人签字(PR 评审即签字)。 + +## ① 架构叙述 + +> This document describes the architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle, from the microkernel design discussion: **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. + +本文介绍 DeepSeek Harness 整体架构,它是 **DeepSeek Code** 的底层基座。微内核设计讨论中确立了核心设计准则:**一切皆插件**。内核刻意做得极精简,仅包含少量抽象服务,外加一个实体循环插件 `dsh-agent-loop`。所有产品功能均基于本文定义的扩展接口开发为独立插件,无需改动主循环逻辑。 + +> Dependency rule: extension plugins depend on 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. + +依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-core`,它的职责是组装整套实体主干。 + +## ② 防御模式规则 + +> 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. + +这些都是踩坑总结得出的缺陷分类规范:下文每种范式都对应一类曾上线、或险些流入线上的问题,每条规范旨在杜绝同类问题复现。编写生命周期、并发、子进程、资源销毁相关代码前,请务必阅读本文档。 + +## ③ 测试政策清单 + +> **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. + +覆盖率门禁(`pnpm run test:coverage`):作为合入门禁校验,要求 `packages/*/*/src` 目录下每个文件行覆盖率达到 100%。未覆盖代码行大多是无用死代码,门禁标记这类代码是提示删除,而非单纯补充测试。行覆盖率是必要条件,但远不充分:它仅能证明代码被执行过,无法保证功能符合线上预期。 + +## ④ 机制描述 + +> 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. + +系统采用文件 blob 哈希而非提交哈希记录状态。同一 PR 内修改文件时,可通过 `git hash-object foo.md` 直接算出对应哈希,仅对比文件内容即可判断双语文档是否同步。通过记录的哈希值,可使用 `git cat-file -p ` 还原上次确认对齐时两侧的原文。当双语文档不一致时,只需对比修改版本与上次确认版本的差异,最小幅度同步修改另一侧译文,无需全文重新翻译。 + +## ⑤ 政策声明 + +> 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 — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. + +明确门禁校验边界:门禁校验通过,仅代表两份文档哈希与结构完全匹配,不代表译文内容准确无误。门禁仅校验哈希与结构,无法判断双语表意是否统一——译文质量把关是评审人的责任。即便译文粗糙、表意偏差,只要哈希匹配,门禁就会放行,但这类 PR 绝不能通过人工评审。 + +## ⑥ RFC 论证 + +> 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. + +对比双语文件的 git 时间戳(无哈希记录方案)——不予采纳:仅调整格式的改动会触发误报,无关修改后再提交译文又会造成漏检。只有文件内容完全一致,才能作为门禁可信的校验依据。 + +## ⑦ 推进策略(长段拆分示范) + +> **Rollout**: new documents don't wait for a batch — a date-named document dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. […] 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. + +**推进**:新增文档不再走批量分批翻译流程。以日期命名的文档,若其标注日期等于或晚于 manifest 里 `requiredSince` 分界时间,提交合入时必须配套对应的双语译文文件——所有新文档从创建起就要求中英双语齐备。针对存量旧文档:manifest 内的强制翻译列表只是当下执行红线,并非最终目标。(……)文档完成双语配对等同于一份长期约束承诺:后续只要修改任一版本,就必须同步更新对应另一语种文件。因此强制翻译范围的推进节奏,要匹配翻译评审实际可投入人力,切勿超前铺开。 + +## 从样例提炼的要点 + +- 语体是规范制度文:完整主谓、确定语气;不口语化,也不学术腔。 +- 给句子补显式执行主体:英文的被动句和抽象主语,中文写成「系统/门禁/工具/评审人」做主语。 +- 用中文工程惯用语替换直译:false positive/negative→误报/漏检、enforcement frontier→执行红线、ratchet→只向前收紧不倒退放宽、reviewable act→评审凭证。 +- 隐喻本地化而非移植:bilingual from birth→从创建起就要求双语齐备;grandfathered→历史存量遗留。 +- 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。 +- 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 +- 母语重写不等于删减:原文每个语义成分都要落地。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f95a8e3a8f..94b182ae4f 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -25,7 +25,9 @@ | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | | backlog | backlog | 双语翻译语境指待翻清单 | -| blob hash | blob hash | git 对象哈希;`git hash-object` 的结果 | +| blob hash | blob 哈希 | git 对象哈希;`git hash-object` 的结果 | +| commit hash | 提交哈希 | | +| hash | 哈希 | 代码与命令中保留英文(如 `git hash-object`、`` 占位符) | | doc-sync | doc-sync | 仓库门禁名,保留英文 | | e2e | e2e | | | fiber | fiber | 首次出现可写:fiber(插件运行时) | diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index 579bd51511..f1aa737d08 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.i18n.yaml @@ -2,5 +2,5 @@ # 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 +translation-rules.md: 3e99aa5432ccd904f702238e9b802a9ed9bf6832 +translation-rules.zh.md: 78778907f959d62dad2c9b4c02baf1f664791a43 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index 4e190f5846..3e99aa5432 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -10,9 +10,18 @@ How to translate between the two sides of a documentation pair in this repo. Bot - 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. +## Voice + +- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the register of the nearest sample; where a sample and a prose rule here disagree, the sample wins. The target is institutional technical Chinese: complete sentences, declarative, neither chatty nor academic. +- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause. +- Give sentences an explicit agent: where the English uses a passive or an abstract subject, name the actor (系统、门禁、评审人). +- Prefer established Chinese engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack English noun chains into verb clauses. +- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs. +- Category nouns render in Chinese with a first-mention English annotation (实操手册(cookbook)); literal directory or file references stay code-formatted English. + ## Structure preservation -The paired files MUST match one to one in: +Shape is enforced by the pairing gate, so the writer never trades fluency against it — write naturally inside the frame. The paired files MUST match one to one in: - heading hierarchy (same levels, same order — heading TEXT is translated), - list shape and numbering, diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 89a1cddd23..78778907f9 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -10,9 +10,18 @@ - 另一侧应当读起来是其语言自然的技术文字,而不是逐词对照。翻译语义,在目标语言语法需要处重组句子,并保持原作者的语域——简练的保持简练。 - 不要翻译不可译的东西:一句话如果依赖源语言的习语而无法自然转换,就翻译它的意思,而不是习语本身。 +## 行文 + +- 语体以 [style-samples.md](style-samples.md) 为校准锚点——人工定稿的金标样例按文体各一组,译文必须对齐最接近的样例语体;样例与本文条款冲突时,以样例为准。目标语体是规范的技术制度文:完整主谓、确定语气,不口语化也不学术腔。 +- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 +- 给句子补显式执行主体:英文的被动句和抽象主语,中文写成「系统、门禁、评审人」等实际执行者做主语。 +- 优先使用中文工程惯用语而非直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻做本地化替换而不是移植,英文名词链展开为动词句。 +- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。 +- 类别名词说中文并在首现括注英文(实操手册(cookbook));指目录或文件本身时保留代码体英文。 + ## 结构保持 -配对的两个文件必须在以下方面一一对应: +形状由配对门禁强制,译者不需要用流畅度去换结构——在框架内自然行文即可。配对的两个文件必须在以下方面一一对应: - 标题层级(相同级别、相同顺序——标题的**文字**要翻译), - 列表形态与编号, diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 92e6eef950..6f05431ced 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -12,6 +12,7 @@ "docs/module-graph.md", "docs/cordis-catalog/", "docs/tool-catalog/", - "docs/i18n/terminology.md" + "docs/i18n/terminology.md", + "docs/i18n/style-samples.md" ] } From 4f852742371fda1eb93abd4477cbd2affda3e2fc Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:17:14 -0700 Subject: [PATCH 04/31] docs(i18n): absorb the with-key-testing gold sample; table rules mock and real-API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gold sample recorded terminology-corrected per the table-wins rule (agent stays English, cancellation renders 取消), and that rule itself lands in the samples' notes. New table rows: mock -> 模拟; API row notes real-API as 真实接口 when attributive. --- docs/i18n/style-samples.md | 9 +++++++++ docs/i18n/terminology.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index d5d920c0d8..4e20e960b6 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -26,6 +26,14 @@ 覆盖率门禁(`pnpm run test:coverage`):作为合入门禁校验,要求 `packages/*/*/src` 目录下每个文件行覆盖率达到 100%。未覆盖代码行大多是无用死代码,门禁标记这类代码是提示删除,而非单纯补充测试。行覆盖率是必要条件,但远不充分:它仅能证明代码被执行过,无法保证功能符合线上预期。 +> 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. The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. + +我们是 DeepSeek:真实接口相关测试不得刻意缩减用例数量。无密钥测试仅能验证底层通路;只有携带有效密钥执行的用例,才能确认 agent 可正常对接真实模型。请大量编写此类测试:包含文件写入类真实提示词、多轮对话、工具调用、流式中途取消等场景。 + +成本最低、收益最高的是**冒烟测试**:拉起完整真实示例,发送一条真实提示并校验整体运行状态。这类用例能捕获一类问题——单元测试全部绿灯,但产品实际运行故障,单靠模拟接口完全无法发现这类缺陷。 + +自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 + ## ④ 机制描述 > 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. @@ -59,3 +67,4 @@ - 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。 - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 +- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent 保留英文、cancellation 译「取消」)。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 94b182ae4f..5ce1efe7b2 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -6,7 +6,7 @@ |---|---|---| | ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | | AI | AI | 首次出现可写:人工智能(AI) | -| API | API | | +| API | API | real-API 作定语时可译「真实接口」(如 real-API tests → 真实接口测试) | | CI | CI | | | CLI | CLI | 首次出现可写:命令行界面(CLI) | | Cordis | Cordis | 保留英文 | @@ -92,6 +92,7 @@ | language switcher | 语言切换行 | i18n 机制词:双语配对文件顶部的互链行 | | memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | +| mock | 模拟 | 如「模拟接口」「模拟模型」;指测试替身 | | mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | From 74699d25ba2079abb497ae104622952f9489f026 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:18:08 -0700 Subject: [PATCH 05/31] docs(i18n): mock stays English per ruling; sample corrected to match --- docs/i18n/style-samples.md | 4 ++-- docs/i18n/terminology.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 4e20e960b6..cb043a19d9 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -30,7 +30,7 @@ 我们是 DeepSeek:真实接口相关测试不得刻意缩减用例数量。无密钥测试仅能验证底层通路;只有携带有效密钥执行的用例,才能确认 agent 可正常对接真实模型。请大量编写此类测试:包含文件写入类真实提示词、多轮对话、工具调用、流式中途取消等场景。 -成本最低、收益最高的是**冒烟测试**:拉起完整真实示例,发送一条真实提示并校验整体运行状态。这类用例能捕获一类问题——单元测试全部绿灯,但产品实际运行故障,单靠模拟接口完全无法发现这类缺陷。 +成本最低、收益最高的是**冒烟测试**:拉起完整真实示例,发送一条真实提示并校验整体运行状态。这类用例能捕获一类问题——单元测试全部绿灯,但产品实际运行故障,单靠 mock 完全无法发现这类缺陷。 自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 @@ -67,4 +67,4 @@ - 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。 - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 -- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent 保留英文、cancellation 译「取消」)。 +- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock 保留英文,cancellation 译「取消」)。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 5ce1efe7b2..36b0244343 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -92,7 +92,7 @@ | language switcher | 语言切换行 | i18n 机制词:双语配对文件顶部的互链行 | | memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | -| mock | 模拟 | 如「模拟接口」「模拟模型」;指测试替身 | +| mock | mock | 保留英文;指测试替身 | | mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | From daaddcaaf3c5ec355b94725d701ae00dceecfc8c Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:24:56 -0700 Subject: [PATCH 06/31] docs(i18n): absorb round-4 gold samples, terminology-corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture pointer paragraph, two defensive patterns, and the prefer-real-over-mock rule join the samples. Recorded per the table-wins rule: agent/mock/LLM stay English, registry renders 注册表, and code-spanned identifiers (event names, status values, package names) survive verbatim — that boundary is now a stated sample note. orphan row notes the process-context rendering 孤儿进程. --- docs/i18n/style-samples.md | 19 ++++++++++++++++++- docs/i18n/terminology.md | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index cb043a19d9..d75fa6e54b 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,12 +14,24 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-core`,它的职责是组装整套实体主干。 +> This document covers **behavior**; type shapes live in [core-data-structures/](core-data-structures/core.md), the per-event/service reference in the [generated catalog](cordis-catalog/events-and-services.md), per-package contracts in the package READMEs ([map](../packages/README.md)). + +本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](cordis-catalog/events-and-services.md);各 package 对外约束协议写在对应包的 README([索引](../packages/README.md))。 + ## ② 防御模式规则 > 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. 这些都是踩坑总结得出的缺陷分类规范:下文每种范式都对应一类曾上线、或险些流入线上的问题,每条规范旨在杜绝同类问题复现。编写生命周期、并发、子进程、资源销毁相关代码前,请务必阅读本文档。 +> **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. + +**销毁操作必须等待所有任务完全停稳,不能仅下发终止指令就返回**——若销毁逻辑仅发送终止、中断信号,但不等任务停止就直接退出,会产生孤儿进程。清理逻辑需设为异步,等待所有子任务彻底退出(先下发终止信号,再等待执行完成);在执行终止操作前先关闭监听器与通知注册表,让延迟到达的完成事件不再触发任何通知。测试要验证销毁流程确实完成等待:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能仅校验进程最终会自行消亡。 + +> **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. + +**异步状态不等同于同步瞬时状态**——调用 `agent.send()` 不会在返回前同步更新状态;后台任务完成时机与轮次边界存在竞态;调用 `reader.close()` 既可能是读到文件末尾,也可能是资源销毁触发。切勿仅凭刚查询到的状态来阻断流程;生命周期逻辑应基于真实触发的事件与 promise 驱动(`agent/status`、`task.done`),观测完整状态切换(先 `running`、再 `idle`),而非主观认定操作和执行轮次一一对应(主循环会批量处理排队消息)。 + ## ③ 测试政策清单 > **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. @@ -34,6 +46,10 @@ 自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 +> **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. + +**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的边界模块做 mock(LLM 适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 + ## ④ 机制描述 > 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. @@ -67,4 +83,5 @@ - 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。 - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 -- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock 保留英文,cancellation 译「取消」)。 +- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock、LLM 保留英文,cancellation 译「取消」)。 +- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写——这是行文规则的硬边界,Pass 2 逐句核验的重点。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 36b0244343..7d3360a689 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -96,7 +96,7 @@ | mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | -| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 | +| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿。进程语境按 OS 惯用语译「孤儿进程」 | | pairing | 配对 | | | permission | 权限 | | | persistence | 持久化 | | From 60cfec6073954b7eeff6f10f03d89b980b84dafa Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:33:24 -0700 Subject: [PATCH 07/31] docs(i18n): retarget sample-excerpt links to resolve from docs/i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relative links copied into an excerpt resolve from the excerpt's home, not the source doc's — verify-md-links caught the moved-directory break. --- docs/i18n/style-samples.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index d75fa6e54b..b72d810afc 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,9 +14,9 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-core`,它的职责是组装整套实体主干。 -> This document covers **behavior**; type shapes live in [core-data-structures/](core-data-structures/core.md), the per-event/service reference in the [generated catalog](cordis-catalog/events-and-services.md), per-package contracts in the package READMEs ([map](../packages/README.md)). +> This document covers **behavior**; type shapes live in [core-data-structures/](../core-data-structures/core.md), the per-event/service reference in the [generated catalog](../cordis-catalog/events-and-services.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). -本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](cordis-catalog/events-and-services.md);各 package 对外约束协议写在对应包的 README([索引](../packages/README.md))。 +本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](../core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](../cordis-catalog/events-and-services.md);各 package 对外约束协议写在对应包的 README([索引](../../packages/README.md))。 ## ② 防御模式规则 From b43a9e9ab1c4fae76a5a1ccde5b71607a1de4215 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:41:04 -0700 Subject: [PATCH 08/31] docs(i18n): package stays English per ruling --- docs/i18n/terminology.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 7d3360a689..0166b87e4c 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -36,6 +36,7 @@ | harness | harness | 保留英文 | | manifest | manifest | 描述模块或工具元数据的文件 | | monorepo | monorepo | | +| package | package | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | | schema DSL | schema DSL | | | schema | schema | 保留英文 | | seam | seam | 首次出现可写:seam(扩展点) | From aa1dc0e2c75df10e309a5af455fcf02086482e91 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 10 Jul 2026 16:51:19 +0800 Subject: [PATCH 09/31] feat(session-query): checkpoint build round 1 --- docs/architecture.md | 3 +- docs/capability-seams.md | 10 +- docs/config-catalog.md | 20 + docs/cordis-catalog/events.md | 24 +- docs/cordis-catalog/services.md | 23 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 7 + docs/rfc/INDEX.md | 2 + .../2026-07-10-session-query-service.md | 61 ++ ...026-07-10-sqlite-session-query-provider.md | 50 ++ packages/README.md | 3 +- .../cordis/tool-cordis/src/api-catalog.ts | 128 ++++ packages/core/session/README.md | 7 +- packages/core/session/src/index.ts | 19 +- packages/core/session/src/surface.ts | 191 +++-- packages/core/session/tests/session.spec.ts | 37 + packages/core/session/tests/surface.spec.ts | 30 +- .../session-persistence/README.md | 2 + .../session-persistence/src/coordinator.ts | 60 +- .../session-persistence/src/index.ts | 23 + .../tests/coordinator-contract.ts | 68 ++ packages/session-query/README.md | 9 + .../session-query/session-query/README.md | 46 ++ .../session-query/session-query/package.json | 44 ++ .../session-query/session-query/src/config.ts | 29 + .../session-query/session-query/src/corpus.ts | 221 ++++++ .../session-query/src/extraction.ts | 258 +++++++ .../session-query/src/filters.ts | 123 +++ .../session-query/session-query/src/index.ts | 225 ++++++ .../session-query/src/provider.ts | 328 ++++++++ .../session-query/src/tracing.ts | 158 ++++ .../session-query/session-query/src/types.ts | 292 ++++++++ .../session-query/tests/session-query.spec.ts | 704 ++++++++++++++++++ .../session-query/session-query/tsconfig.json | 30 + pnpm-lock.yaml | 19 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-module-graph.ts | 1 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 40 files changed, 3174 insertions(+), 102 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-10-session-query-service.md create mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md create mode 100644 packages/session-query/README.md create mode 100644 packages/session-query/session-query/README.md create mode 100644 packages/session-query/session-query/package.json create mode 100644 packages/session-query/session-query/src/config.ts create mode 100644 packages/session-query/session-query/src/corpus.ts create mode 100644 packages/session-query/session-query/src/extraction.ts create mode 100644 packages/session-query/session-query/src/filters.ts create mode 100644 packages/session-query/session-query/src/index.ts create mode 100644 packages/session-query/session-query/src/provider.ts create mode 100644 packages/session-query/session-query/src/tracing.ts create mode 100644 packages/session-query/session-query/src/types.ts create mode 100644 packages/session-query/session-query/tests/session-query.spec.ts create mode 100644 packages/session-query/session-query/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 01ae6ef7cb..8a9cb0a4f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live/persisted session retrieval and search-provider coordination | ## Event @@ -156,4 +157,4 @@ The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeleton - Exact event and service signatures in [events](cordis-catalog/events.md) - [services](cordis-catalog/services.md) catalogs - package contracts in the [package map](../packages/README.md) -- [RFCs](rfc/README.md) \ No newline at end of file +- [RFCs](rfc/README.md) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1c24a2916e..dc2f0a05bc 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -18,12 +18,14 @@ flowchart LR svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] pkg_session_persistence["session-persistence"] + pkg_session_query["session-query"] 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"] + svc_sessionQuery["ctx.sessionQuery
Session retrieval read model"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -90,6 +92,7 @@ flowchart LR pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence + pkg_session_query --> svc_sessionQuery pkg_stdio_agent --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents @@ -122,10 +125,12 @@ flowchart LR svc_llm --> pkg_compact_basic svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop + svc_sessionPersistence --> pkg_session_query svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence + svc_sessions --> pkg_session_query svc_sessions --> pkg_subagent_inprocess svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -152,8 +157,9 @@ flowchart LR | 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.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`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), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers. | | `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-cordis`](../packages/cordis/tool-cordis), [`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/ui/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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 772924df9c..d008878b6a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -483,6 +483,26 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-query` + +Requires: `sessions` + +```ts config-catalog +/** Configuration for the provider-neutral session-query service. */ +export interface Config { + /** Explicit provider id; omitted auto-selects exactly one usable provider. */ + searchProvider?: string + /** Default search result page size. Defaults to 20. */ + defaultLimit?: number + /** Maximum accepted search page size. Defaults to 100. */ + maxLimit?: number + /** Maximum accepted raw read context on either side. Defaults to 50. */ + readWindowMax?: number +} +``` + +Source: [`packages/session-query/session-query/src/config.ts:17`](../packages/session-query/session-query/src/config.ts) + ## `@deepseek-ai/dsh-stdio-agent` ```ts config-catalog diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a9dfb7e05c..42be61a24e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -237,7 +237,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -247,7 +247,27 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:65`](../../packages/core/session/src/index.ts) + +### `session/persisted` — parallel + +A persistence backend committed a canonical session-log change. This is an observe-only notification for derived read models: the durable write has already succeeded, and listener failures are contained rather than propagated into append, load, flush, or teardown. + +```ts cordis-catalog +'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void +``` + +Source: [`packages/session-persistence/session-persistence/src/index.ts:50`](../../packages/session-persistence/session-persistence/src/index.ts) + +### `session/removed` — parallel + +A session left the live store. The header is snapshotted after the store entry is removed; listener failures are contained and cannot break the owning fiber's teardown. + +```ts cordis-catalog +'session/removed'(header: SessionHeader): Promise | void +``` + +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 79902ba2fc..04d07f5795 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -167,7 +167,26 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:125`](../../packages/session-persistence/session-persistence/src/index.ts) + +## `ctx.sessionQuery` — `SessionQueryService` + +Session-history retrieval and provider coordination service. + +```ts cordis-catalog +listSessions(): Promise +async listEvents(sessionId: SessionId): Promise +async readEvent(request: SessionEventReadRequest): Promise +async traceSession(sessionId: SessionId): Promise +async traceEvent(sessionId: SessionId, seq: number): Promise +registerSearchProvider(provider: SessionSearchProvider): () => void +registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void +registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void +searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> +searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise> +``` + +Source: [`packages/session-query/session-query/src/index.ts:59`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -185,7 +204,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:413`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index acbe700965..23e3844375 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,8 +24,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../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:47`](../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:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../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:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 68c680c914..6e479d2111 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -75,6 +75,9 @@ flowchart TD pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] end + subgraph group_session_query["packages/session-query"] + pkg_session_query["session-query"] + end subgraph group_support["packages/support"] pkg_acp_snapshot["acp-snapshot"] pkg_invariants["invariants"] @@ -145,6 +148,9 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence pkg_invariants --> pkg_agent pkg_invariants --> pkg_llm pkg_invariants --> pkg_session @@ -297,6 +303,7 @@ flowchart TD | [`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) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`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) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ba2b9d88dc..4c77469f63 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,6 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | +| [SQLite FTS5 session-query provider](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | ### Simplification @@ -67,6 +68,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | +| [Provider-neutral session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md new file mode 100644 index 0000000000..eeb3a13fc7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -0,0 +1,61 @@ +# RFC: Provider-neutral session query service + +Status: implemented + +## Problem + +Session logs contain the harness's durable working memory, but the existing services expose them only as live objects or backend-specific persisted records. Consumers that want history search, compacted-event recall, lineage inspection, or another agent's status otherwise have to choose a storage backend, duplicate live-versus-persisted precedence, and reconstruct surface provenance independently. Live state also advances between persistence checkpoints, so treating durable storage as the only query source makes current-turn reads stale. + +Search is only one operation in that read model. Metadata filtering must compose without another database round trip, event and session lineage need deterministic graph semantics, and an event read must return exact canonical content rather than a search snippet. Folding all of those responsibilities into one SQLite package would make storage technology the public API and would prevent live-only deployments from using the non-search capabilities. + +## Decision + +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a trusted provider-neutral read model over one logical corpus: live `SessionStore` entries plus an optional, dynamically mounted `SessionPersistence` service. Matching ids resolve to one record. Live events take precedence because they include appends after the latest checkpoint; the record still exposes independent `live` and `persisted` flags. The service compares immutable headers and fails with a typed source-conflict error when the two sources cannot represent the same session. + +The service owns source observation, reconciliation, precedence, cloning, filters, tracing, extraction, and provider selection. It exposes lightweight session and event records, bounded exact-event reads, complete known session lineage, event surface/provenance traces, and two full-text scopes. A search backend owns only indexing, ranking, snippets, cursors, and backend-specific query validation. + +Persistence is optional. Live-only reads and provider synchronization work without it. Unmounting persistence hides the provider's durable base rather than deleting derived cache rows, so remounting can reuse fingerprints. An installed but unreadable backend fails cross-session operations; a read of a known live session remains independent of that failure. + +## Surface and lineage semantics + +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seq range. Session-query derives `current`, `shadowed`, and `log-only` classifications and replacement chains from that result, so query and model-history derivation cannot disagree about positional replacement semantics. + +Event traces accept any raw event. They return direct `sourceEventSeqs` references, reverse references, nodes directly shadowed by a replacement, its immediate replacer, and the transitive replacement chain toward the current surface. Related content is deliberately not embedded; exact content remains the job of the bounded event read. + +Session traces walk parents nearest-first. A complete chain reports its root; a partial corpus reports the first unresolved parent id. Descendants form a complete known tree ordered by creation time and id. A cycle connected to the target is an invalid lineage error rather than a truncated result. + +## Filters and public records + +Serializable discriminated filter specs cover session identity, cwd, creation time, parent/root, availability, event seq/time/type, and surface status. Alternatives within one spec are OR; specs in a supplied array are AND. The exported generic transforms are pure, preserve order and item identity, and work on base records or richer hits. Search requests accept the same specs before ranking. Applying a transform to one materialized page never triggers a refill. + +Public records are intentionally small. `SessionRecord` carries a cloned header and source flags. `SessionEventRecord` carries session id, seq, type, time, and surface status. Search adds a plain snippet to event hits and exactly one best event to session hits; numeric provider scores remain private. Search pages default to 20 and reject limits above 100. Exact event reads default to no neighbors and cap each side with the configurable `readWindowMax`, default 50. + +## Lifecycle notifications + +Two observe-only Cordis notifications keep derived read models current without joining the write transaction. `session/removed` fires after a live entry leaves `SessionStore`. `session/persisted` fires only after an ordinary append or load-time repair commits and carries the affected seq range. Both snapshot their payloads and contain synchronous dispatch errors and rejected listeners, so observers cannot fail session teardown or durability. + +A persistence load preserves an existing live owner in coordinator state. HMR adoption of a torn durable prefix truncates only the uncommitted fragment while the live session remains authoritative; it does not publish a repair notification or synthesize an interrupted turn mid-turn. A later real append produces the ordinary committed notification. + +## Provider and extractor contracts + +A selected search provider receives separate persisted-base and live-override operations. Persisted reconciliation begins inactive, compares the provider inventory with SHA-256 fingerprints over canonicalized header/events and relevant extractor versions, replaces only changed sessions, removes proven-stale rows, and then activates the base. Live snapshots always replace the matching override; removal reveals an active persisted base. Search waits for relevant queued reconciliation, with corpus scope for session search and target scope for a live event search. A failed update stays retryable and fails affected searches with a typed derived-index error without affecting canonical writes. Caller cancellation stops waiting and reaches provider query work through `AbortSignal`. + +Core extractors cover semantic messages, reasoning, tools, todos, blocked prompts, context and steering, and error/status detail. Chunks, request headers, and structural events add no document. Declaration-merged event and content-block owners can install one effect-scoped extractor per type with a stable version; unknown types stay non-searchable. + +## Security boundary + +The service is context-wide trusted infrastructure, not an authorization layer. A model-facing history tool or human UI applies explicit caller/session scope before invoking cross-session operations. This decision exposes no unscoped model tool and changes no transcript or snapshot surface. + +## Alternatives considered + +- **Put all query behavior in a SQLite implementation** — rejected because filters, exact reads, source precedence, lineage, and surface provenance are storage-independent, and live-only deployments still need them. It would also let backend details become the public service contract. +- **Query only persisted sessions** — rejected because persistence checkpoints occur at turn boundaries; a current live session would be stale precisely when an agent inspects its latest work. +- **Mirror every live append into persistence before querying** — rejected because query observation must not add durability latency or change the turn checkpoint contract. The live override is an ephemeral derived layer. +- **Express every chained filter as SQL** — rejected because post-filters operate over already materialized pages and must preserve item identity and caller-chosen composition. Serializable pure transforms also remain usable without a search provider. +- **Make session-query part of the compaction capability** — rejected because retrieval reads all session structure and has consumers beyond recall; compaction is one producer of replacement provenance, not the owner of the read model. + +## Consequences + +Consumers gain one coherent API for current and durable history, deterministic traces, and backend-neutral search. Derived index failures and optional persistence are isolated from canonical session writes, and unchanged persisted sessions can reuse provider rows across restarts. + +The service carries non-trivial reconciliation state and performs canonical log loads to validate fingerprints. Cross-session search intentionally waits for whole-corpus synchronization, and live precedence means providers must implement a two-layer model. Authorization remains the responsibility of future consumers. Full-text search is unavailable until an implementation package registers a provider; that implementation is intentionally outside this decision's package. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md new file mode 100644 index 0000000000..08c1c6fc75 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -0,0 +1,50 @@ +# RFC: SQLite FTS5 session-query provider + +Status: proposed + +## Problem + +The provider-neutral session-query service defines full-text scopes and synchronization but deliberately ships no index. A first backend must search semantic event documents across large persisted histories without rebuilding unchanged sessions at every process start, while keeping unflushed live overrides current and disposable. It also needs deterministic ranking and pagination semantics strong enough for model tools and UI clients to continue a result set safely. + +Using the canonical session-persistence database directly would couple two failure domains and schemas: query rows are derived and rebuildable, while session logs are authoritative. A query schema reset, corrupt index, or experimental tokenizer must never endanger durable conversation history. + +## Proposal + +Add an `@deepseek-ai/dsh-session-query-sqlite` implementation in a separate phase-two pull request after the provider-neutral phase is complete. It will register one `SessionSearchProvider` on `ctx.sessionQuery` and own a separate derived SQLite database. Persisted event documents survive provider restarts; live overrides remain connection-local and disappear when the provider closes. + +The provider will use SQLite FTS5 with the trigram tokenizer. A query splits on whitespace and requires every term. Terms shorter than three characters fail with a typed provider error rather than silently changing matching semantics. Each searchable event is one document, including current, shadowed, and log-only states by default. Event search ranks documents within one session; session search groups by session and ranks it by exactly one strongest matching event. Ties are deterministic, public hits contain plain-text snippets, and numeric FTS scores remain internal. + +## Storage and reconciliation + +The database path, journal mode, page/result limits, and snippet length are validated configuration. Durable tables store provider schema version, persisted-session fingerprints, lightweight session metadata, event metadata, text, and the FTS virtual table. A provider-schema mismatch is the exceptional full reset; ordinary startup calls `persistedInventory()` and lets the service replace only new or changed sessions and remove canonical deletions. + +The live layer uses temporary or connection-local tables with the same searchable shape. A live snapshot shadows every persisted document for that session. Removing the override reveals the active persisted base. `setPersistedActive(false)` excludes durable rows from results without deleting their fingerprint cache. Reopening the database proves that persisted rows remain and live rows do not. + +## Query and cursor semantics + +Search request filters compile to parameterized metadata predicates before FTS ranking. Query terms are escaped as data, never interpolated into FTS syntax. Snippets are plain text with bounded length and no provider-specific markup contract. + +Opaque cursors bind to the normalized request shape and a generation. Session-search cursors bind to the global logical-corpus generation. Event-search cursors bind only to the target session generation. A relevant change makes the cursor stale and produces a typed error; unrelated session changes do not invalidate an inner-session cursor. Stable tie fields are encoded after rank so resumed pages neither duplicate nor skip hits. + +Provider update operations are transactional. An index write failure leaves the prior committed generation queryable only after the owning service has successfully retried the dirty update; affected searches fail rather than returning a knowingly stale page. Abort signals interrupt waits and SQLite query work where the runtime permits. + +## Alternatives considered + +- **Use the session-persistence SQLite database and add FTS tables there** — rejected because derived-index schema churn, resets, and corruption recovery must not share the authoritative log's transaction or failure boundary. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing persistence checkpoint commits. Ephemeral overlay rows preserve read-your-writes without inventing a second durability path. +- **Use the default FTS5 unicode tokenizer** — rejected for the first backend because substring-oriented history recall is a core use case. Trigram search gives predictable mid-token matching at the accepted cost of rejecting sub-three-character terms. +- **Return raw BM25 scores** — rejected because scores are provider-specific and unstable across corpus changes. Ranking is observable; numeric scale is not part of the service API. +- **Keep cursors valid across index changes** — rejected because rank and grouping can move after a relevant write, making continued pages duplicate or omit hits. + +## Acceptance criteria + +- Restart tests prove an unchanged persisted fingerprint performs no FTS replacement, while new, changed, and deleted sessions reconcile correctly. +- Reopening proves persisted rows survive, live rows disappear, removing a live override reveals its persisted base, and the provider works with no persistence service. +- Tests cover both search scopes, all metadata filters, surface defaults, snippets, AND-term escaping, short-term rejection, deterministic ties, pagination, request-bound cursors, scoped stale generations, cancellation, and recovery after a failed index update. +- A provider-schema mismatch resets only the derived database. Normal source changes never trigger a full reset. +- A keyless end-to-end restart test combines a real persistence backend with the real SQLite query provider. +- The implementation, package wiring, and tests land only in the separate phase-two pull request; phase one contains this proposal but no SQLite query code. + +## Risks + +Trigram indexes use more space than word-token indexes, and loading canonical logs to recompute fingerprints still has startup I/O cost even when FTS replacement is skipped. FTS5 ranking and snippet behavior can differ across SQLite runtime versions, so deterministic tie fields and provider-owned snippet tests must pin only the contract the package controls. A global generation makes cross-session cursors conservative: any corpus change invalidates them. The separate derived database adds configuration and lifecycle work, but it preserves the authoritative store's safety boundary. diff --git a/packages/README.md b/packages/README.md index a656812253..8bc5c07637 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,6 @@ # 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, 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. +Harness packages use the `@deepseek-ai/dsh-*` scope and Cordis plugin model. Authoring conventions live in [packages/AGENTS.md](AGENTS.md) and the root [AGENTS.md](../AGENTS.md) § Conventions. ## Hierarchy @@ -23,6 +23,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 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 | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, filters, tracing, and full-text provider seam | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | 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 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 68fd63ae25..7b3cd1297f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -135,6 +135,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract list(): Promise', ], }, + { + key: 'sessionQuery', + summary: 'Session-history retrieval and provider coordination service.', + methods: [ + 'listSessions(): Promise', + 'async listEvents(sessionId: SessionId): Promise', + 'async readEvent(request: SessionEventReadRequest): Promise', + 'async traceSession(sessionId: SessionId): Promise', + 'async traceEvent(sessionId: SessionId, seq: number): Promise', + 'registerSearchProvider(provider: SessionSearchProvider): () => void', + 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', + 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', + 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', + 'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise>', + ], + }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -321,6 +337,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/flush\'(session: Session): Promise | void', summary: 'Awaited durability checkpoint.', }, + { + name: 'session/persisted', + mode: 'parallel', + signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise | void', + summary: 'A persistence backend committed a canonical session-log change.', + }, + { + name: 'session/removed', + mode: 'parallel', + signature: '\'session/removed\'(header: SessionHeader): Promise | void', + summary: 'A session left the live store.', + }, { name: 'subagent/end', mode: 'emit', @@ -677,6 +705,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, + { + name: 'SessionContentTextExtractor', + declaration: 'export interface SessionContentTextExtractor {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -685,10 +717,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */', }, + { + name: 'SessionEventReadRequest', + declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}', + }, + { + name: 'SessionEventRecord', + declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', + }, + { + name: 'SessionEventResultFilter', + declaration: 'export type SessionEventResultFilter = {\n kind: \'seq\';\n range: SessionQueryRange;\n} | {\n kind: \'time\';\n range: SessionQueryRange;\n} | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n};', + }, + { + name: 'SessionEventSearchHit', + declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', + }, + { + name: 'SessionEventSearchRequest', + declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', + }, + { + name: 'SessionEventSurface', + declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', + }, + { + name: 'SessionEventTextExtractor', + declaration: 'export interface SessionEventTextExtractor {\n version: string;\n extract(event: SessionEvent): readonly string[];\n}', + }, + { + name: 'SessionEventTrace', + declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n shadowedBy?: number;\n replacementChain: number[];\n shadows: number[];\n references: number[];\n referencedBy: number[];\n}', + }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', }, + { + name: 'SessionEventWindow', + declaration: 'export interface SessionEventWindow {\n session: SessionRecord;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', + }, { name: 'SessionForkSource', declaration: 'export type SessionForkSource = Session | SessionId;', @@ -701,6 +769,66 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionIndexDocument', + declaration: 'export interface SessionIndexDocument extends SessionEventRecord {\n text: string;\n}', + }, + { + name: 'SessionIndexSnapshot', + declaration: 'export interface SessionIndexSnapshot {\n session: SessionRecord;\n fingerprint: string;\n documents: readonly SessionIndexDocument[];\n}', + }, + { + name: 'SessionLineageNode', + declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n children: SessionLineageNode[];\n}', + }, + { + name: 'SessionLineageTrace', + declaration: 'export interface SessionLineageTrace {\n target: SessionRecord;\n parents: SessionRecord[];\n root?: SessionRecord;\n unresolvedParentId?: SessionId;\n children: SessionLineageNode[];\n}', + }, + { + name: 'SessionPersistedIndexEntry', + declaration: 'export interface SessionPersistedIndexEntry {\n sessionId: SessionId;\n fingerprint: string;\n}', + }, + { + name: 'SessionQueryExecContext', + declaration: 'export interface SessionQueryExecContext {\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'SessionQueryRange', + declaration: 'export interface SessionQueryRange {\n from?: number;\n to?: number;\n}', + }, + { + name: 'SessionRecord', + declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', + }, + { + name: 'SessionResultFilter', + declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | {\n kind: \'created-at\';\n range: SessionQueryRange;\n} | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly (\'live\' | \'persisted\')[];\n};', + }, + { + name: 'SessionSearchHit', + declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', + }, + { + name: 'SessionSearchPage', + declaration: 'export interface SessionSearchPage {\n providerId: string;\n items: readonly T[];\n nextCursor?: string;\n}', + }, + { + name: 'SessionSearchPageRequest', + declaration: 'export interface SessionSearchPageRequest {\n limit?: number;\n cursor?: string;\n}', + }, + { + name: 'SessionSearchProvider', + declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise>;\n}', + }, + { + name: 'SessionSearchProviderStatus', + declaration: 'export type SessionSearchProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'misconfigured\' | \'unavailable\';\n};', + }, + { + name: 'SessionSearchRequest', + declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 1cc393e172..89d2ffdf03 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -25,11 +25,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Events -| Event | Mode | Purpose | -|---|---|---| -| `session/created` | emit | A session was created | -| `session/event` | emit | An event was appended (sync, fire-and-forget) | -| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | +The generated [Cordis event catalog](../../../docs/cordis-catalog/events.md) is the signature reference. `session/removed` is an observe-only notification emitted with a cloned header after the entry leaves the store; listener failures cannot fail owner teardown. ### Class: `Session` @@ -47,6 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. +- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges. `SurfaceManager` shares the same transitions while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cd9e1828b2..88cb198c49 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -20,8 +20,8 @@ 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' +export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' +export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -37,6 +37,14 @@ declare module 'cordis' { * @mode emit */ 'session/created'(session: Session): void + /** + * A session left the live store. The header is snapshotted after the store + * entry is removed; listener failures are contained and cannot break the + * owning fiber's teardown. + * @param header - immutable identity and lineage of the removed session. + * @mode parallel + */ + 'session/removed'(header: SessionHeader): Promise | 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. @@ -501,8 +509,15 @@ export class SessionStore extends Service { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(session.id, session) return () => { + if (this.store.get(session.id) !== session) return session.onAppend = undefined this.store.delete(session.id) + const header = structuredClone(session.header) + void Promise.resolve() + .then(() => this.ctx.parallel('session/removed', header)) + .catch((error: unknown) => { + this.ctx.logger.warn(`session store: session/removed listener failed for "${session.id}": ${String(error)}`) + }) } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7219856bdb..60c3633a1b 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -61,6 +61,125 @@ export interface SurfaceNode { next: number | null } +/** One replacement operation observed while folding a session surface. */ +export interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ + seq: number + /** Declared inclusive start seq of the replaced surface range. */ + start: number + /** Declared inclusive end seq of the replaced surface range. */ + end: number + /** Actual surface nodes removed by the operation, in surface order. */ + shadowedSeqs: number[] +} + +/** Complete result of replaying the surface operations in a session log. */ +export interface SurfaceFoldResult { + /** Current surface nodes in linked-list order. */ + nodes: SurfaceNode[] + /** Replacement operations in event order. */ + replacements: SurfaceFoldReplacement[] +} + +/** Mutable state shared by the incremental manager and the full-log fold. */ +interface SurfaceFoldState { + nodes: SurfaceNode[] + nodeBySeq: Map + replacements: SurfaceFoldReplacement[] + replaceGeneration: number +} + +/** Create an empty surface fold state. */ +function createFoldState(replaceGeneration = 0): SurfaceFoldState { + return { + nodes: [], + nodeBySeq: new Map(), + replacements: [], + replaceGeneration, + } +} + +/** Apply one event to a surface fold state. */ +function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { + if (!isSurfaceEvent(event)) return + + if (event.surfaceOp === 'append') { + const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined + const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } + if (tail) tail.next = event.seq + state.nodes.push(node) + state.nodeBySeq.set(event.seq, node) + return + } + + const shadowedSeqs = replaceSurface(state, event.seq, event.surfaceOp) + state.replacements.push({ + seq: event.seq, + start: event.surfaceOp.start, + end: event.surfaceOp.end, + shadowedSeqs, + }) +} + +/** Apply one positional replacement and return the nodes it removed. */ +function replaceSurface( + state: SurfaceFoldState, + newSeq: number, + op: Extract, +): number[] { + const startNode = state.nodeBySeq.get(op.start) + if (!startNode) { + throw new Error(`surface replace: start seq ${op.start} not found in surface`) + } + const endNode = state.nodeBySeq.get(op.end) + if (!endNode) { + throw new Error(`surface replace: end seq ${op.end} not found in surface`) + } + const startIdx = state.nodes.indexOf(startNode) + const endIdx = state.nodes.indexOf(endNode) + if (startIdx > endIdx) { + throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) + } + + const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1) + for (const node of removed) state.nodeBySeq.delete(node.seq) + + const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined + const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined + const newNode: SurfaceNode = { + seq: newSeq, + prev: prevNode?.seq ?? null, + next: nextNode?.seq ?? null, + } + if (prevNode) prevNode.next = newSeq + if (nextNode) nextNode.prev = newSeq + state.nodes.splice(startIdx, 0, newNode) + state.nodeBySeq.set(newSeq, newNode) + state.replaceGeneration += 1 + return removed.map(node => node.seq) +} + +/** + * Replay a complete session log through the canonical surface fold. + * + * The returned arrays and nodes are detached snapshots. The incremental + * {@link SurfaceManager} uses the same transition functions, so query read + * models cannot disagree with `deriveMessages()` about replacement ranges. + * @param events - session events in contiguous seq order. + * @returns the current surface and every positional replacement. + */ +export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { + const state = createFoldState() + for (const event of events) applySurfaceEvent(state, event) + return { + nodes: state.nodes.map(node => ({ ...node })), + replacements: state.replacements.map(replacement => ({ + ...replacement, + shadowedSeqs: [...replacement.shadowedSeqs], + })), + } +} + /** * Maintains a cached linked list of surface nodes, rebuilt lazily from * `surfaceOp` markers in the event log. Because the log is append-only, it @@ -69,16 +188,11 @@ export interface SurfaceNode { * whole log. */ export class SurfaceManager { - /** Surface nodes in linked-list order (head to tail). Empty until first access. */ - private _nodes: SurfaceNode[] = [] - /** Map from event seq → node. */ - private _nodeBySeq = new Map() + /** Incremental state shared with the complete surface fold. */ + private _state = createFoldState() /** The last processed seq. -1 forces a full rebuild on first access. */ private _lastProcessedSeq = -1 - /** Rewrite generation — see {@link replaceGeneration}. */ - private _replaceGeneration = 0 - constructor(private log: readonly SessionEvent[]) {} /** @@ -88,11 +202,9 @@ export class SurfaceManager { */ invalidate(): void { this._lastProcessedSeq = -1 - this._nodes = [] - this._nodeBySeq.clear() // A wholesale rebuild is a rewrite: bump the generation so incremental // consumers (the session's derived-message cache) discard their view. - this._replaceGeneration += 1 + this._state = createFoldState(this._state.replaceGeneration + 1) } /** @@ -106,13 +218,13 @@ export class SurfaceManager { */ get replaceGeneration(): number { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - return this._replaceGeneration + return this._state.replaceGeneration } /** The surface nodes in linked-list order (head to tail). */ get nodes(): readonly SurfaceNode[] { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - return this._nodes + return this._state.nodes } /** @@ -124,61 +236,8 @@ export class SurfaceManager { // Index is bounded by i < this.log.length — never undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! - // isSurfaceEvent checks event.type first (is it a surface-eligible type?) - // then checks that surfaceOp is present. Only after both pass do we treat - // it as a SurfaceEvent with mandatory surfaceOp. - if (!isSurfaceEvent(event)) continue - - if (event.surfaceOp === 'append') { - const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined - const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = event.seq - this._nodes.push(node) - this._nodeBySeq.set(event.seq, node) - } else { - this._replace(event.seq, event.surfaceOp) - } + applySurfaceEvent(this._state, event) } this._lastProcessedSeq = this.log.length - 1 } - - /** Apply a replace operation to the in-progress surface. */ - private _replace( - newSeq: number, - op: Extract, - ): void { - const startNode = this._nodeBySeq.get(op.start) - if (!startNode) { - throw new Error(`surface replace: start seq ${op.start} not found in surface`) - } - const endNode = this._nodeBySeq.get(op.end) - if (!endNode) { - throw new Error(`surface replace: end seq ${op.end} not found in surface`) - } - const startIdx = this._nodes.indexOf(startNode) - const endIdx = this._nodes.indexOf(endNode) - if (startIdx > endIdx) { - throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) - } - - // Remove shadowed nodes from `[startIdx, endIdx]` inclusive. - const count = endIdx - startIdx + 1 - const removed = this._nodes.splice(startIdx, count) - for (const r of removed) this._nodeBySeq.delete(r.seq) - - // Insert the new node where the removed range was. - const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined - const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined - - const newNode: SurfaceNode = { - seq: newSeq, - prev: prevNode?.seq ?? null, - next: nextNode?.seq ?? null, - } - if (prevNode) prevNode.next = newSeq - if (nextNode) nextNode.prev = newSeq - this._nodes.splice(startIdx, 0, newNode) - this._nodeBySeq.set(newSeq, newNode) - this._replaceGeneration += 1 - } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f338b635f3..e51ed923df 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -350,6 +350,43 @@ describe('SessionStore', () => { expect(observed).toBe(0) }) + it('announces a cloned header only after the session leaves the store', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const observations: Array<{ id: string; live: boolean }> = [] + ctx.on('session/removed', (header) => { + observations.push({ id: header.id, live: ctx.sessions.get(header.id) !== undefined }) + header.createdAt = -1 + }) + const session = ctx.sessions.prepare(SessionId('removed'), { meta: { createdAt: 7 } }) + const detach = ctx.sessions.enter(session) + + detach() + await Promise.resolve() + await Promise.resolve() + + expect(observations).toEqual([{ id: 'removed', live: false }]) + expect(session.header.createdAt).toBe(7) + // A repeated disposer cannot remove or announce a later same-id owner. + const replacement = ctx.sessions.create(SessionId('removed')) + detach() + expect(ctx.sessions.get(replacement.id)).toBe(replacement) + expect(observations).toHaveLength(1) + }) + + it('contains rejected session/removed listeners during teardown', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) + const session = ctx.sessions.prepare(SessionId('contained')) + const detach = ctx.sessions.enter(session) + + expect(detach).not.toThrow() + await Promise.resolve() + await Promise.resolve() + expect(ctx.sessions.get(session.id)).toBeUndefined() + }) + it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 257828e466..e127b4a4b3 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -14,6 +14,34 @@ function surfaceSession(): Session { } describe('SurfaceManager', () => { + it('shares exact nodes and nested replacement ranges with foldSurface', () => { + const s = new Session(SessionId('shared-fold')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) + s.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) + + const folded = foldSurface(s.events) + expect(folded.nodes).toEqual(s.surface.nodes) + expect(folded.replacements).toEqual([ + { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, + { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] }, + ]) + folded.nodes[0]!.next = 99 + folded.replacements[0]!.shadowedSeqs.push(99) + expect(s.surface.nodes).toEqual([{ seq: 3, prev: null, next: null }]) + expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) + }) + + it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { + const s = new Session(SessionId('shared-fold-invalid')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] }) + + expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/) + expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) + }) + it('rebuilds a linked list from surfaceOp: append markers', () => { const s = surfaceSession() const nodes = s.surface.nodes diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 8bd3fed568..557b968609 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -26,6 +26,8 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of `PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn. + The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0180999842..b04387dab1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix } from './index.ts' +import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -229,13 +229,13 @@ export class PersistenceCoordinator { // event inside it — before the op runs would otherwise have those changes // persisted. The clone is taken synchronously (at call time). const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this.appendCore(id, batch)) + return this.serialize(id, () => this._appendCore(id, batch)) } - private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { if (events.length === 0) return let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls loadCore, not load + if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load // Contiguity contract: each event's seq must continue the stored log. for (const [i, event] of events.entries()) { @@ -247,8 +247,14 @@ export class PersistenceCoordinator { await this.backend.appendBatch(state.meta, events, state.materialized) // The durable write is the transaction: mark materialized + advance the // cursor as soon as it commits (uniform across backends). + const fromSeq = state.cursor state.materialized = true state.cursor += events.length + this._notifyPersisted(state.meta, { + kind: 'append', + fromSeq, + toSeq: state.cursor - 1, + }) } /** @@ -259,10 +265,10 @@ export class PersistenceCoordinator { * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this.loadCore(id)) + return this.serialize(id, () => this._loadCore(id)) } - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored @@ -281,10 +287,21 @@ export class PersistenceCoordinator { // there is no state-path ordering dependency (uniform across backends). if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) + this._notifyPersisted(meta, { + kind: 'repair', + fromSeq: events.length, + toSeq: balanced.length - 1, + }) } - // The state keeps its OWN copy of the meta; the returned value is separate so - // a consumer mutating loaded.meta cannot corrupt the backend's metadata. - this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) + const owner = this.states.get(id)?.owner + // The state keeps its OWN copy of the meta; preserve a live owner already + // bound to the id so a read-side load cannot downgrade adoption state. + this.states.set(id, { + meta: { ...meta }, + cursor: balanced.length, + materialized: true, + ...owner !== undefined ? { owner } : {}, + }) return { meta, events: balanced } } @@ -314,11 +331,11 @@ export class PersistenceCoordinator { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise { - // loadCore (NOT load) — adopt runs inside an already-serialized op, so + // _loadCore (NOT load) — adopt runs inside an already-serialized op, so // re-entering the chain via the public load() would deadlock. - await this.loadCore(id) + await this._loadCore(id) const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ + /* v8 ignore next -- _loadCore always sets the state for the id */ if (!state) throw new Error(`failed to adopt session "${id}"`) return state } @@ -475,7 +492,7 @@ export class PersistenceCoordinator { // resume. const live = await this.backend.loadLive(id, session.header.cwd) if (live !== undefined) { - // Do NOT route through loadCore(): that crash-repairs open turns as + // Do NOT route through _loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) @@ -515,7 +532,7 @@ export class PersistenceCoordinator { owner: session, }) const suffix = seed.slice(events.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + if (suffix.length > 0) await this._appendCore(session.header.id, suffix) } private async flush(session: Session): Promise { @@ -546,9 +563,20 @@ export class PersistenceCoordinator { /* v8 ignore next -- state is always set by the awaited init before flush */ const cursor = state?.cursor ?? 0 const fresh = batch.filter(e => e.seq >= cursor) - // appendCore (NOT the serialized append) — drain already runs inside the + // _appendCore (NOT the serialized append) — drain already runs inside the // per-session chain, so re-entering via append() would deadlock. - if (fresh.length > 0) await this.appendCore(session.header.id, fresh) + if (fresh.length > 0) await this._appendCore(session.header.id, fresh) buffer.splice(0, batch.length) } + + /** Notify derived read models after source data commits. */ + private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void { + const header = structuredClone(meta) + const snapshot = structuredClone(change) + void Promise.resolve() + .then(() => this.ctx.parallel('session/persisted', header, snapshot)) + .catch((error: unknown) => { + this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`) + }) + } } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 1588ed2526..ea98f70e9f 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -36,6 +36,29 @@ declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence } + + interface Events { + /** + * A persistence backend committed a canonical session-log change. This is + * an observe-only notification for derived read models: the durable write + * has already succeeded, and listener failures are contained rather than + * propagated into append, load, flush, or teardown. + * @param header - snapshotted persisted session metadata. + * @param change - committed seq range and whether it was an append or repair. + * @mode parallel + */ + 'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void + } +} + +/** A committed persisted-log change observed by derived read models. */ +export interface SessionPersistedChange { + /** Whether ordinary append or load-time repair committed the change. */ + kind: 'append' | 'repair' + /** First seq affected by the commit. */ + fromSeq: number + /** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */ + toSeq: number } /** diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 42583c4fe3..6d95fbb4b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -31,6 +31,7 @@ import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '../src/index.ts' +import type { SessionPersistedChange } from '../src/index.ts' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -123,6 +124,40 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('announces committed append and repair ranges without coupling listener failures to writes', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] + ctx.on('session/persisted', (header, change) => { + observed.push({ headerId: header.id, change: structuredClone(change) }) + header.createdAt = -1 + return Promise.reject(new Error('derived read model failed')) + }) + try { + const m = meta('notifications', WORK) + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + ]) + await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } }) + await Promise.resolve() + await Promise.resolve() + + expect(observed).toEqual([ + { headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } }, + { headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } }, + { headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } }, + ]) + expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the // seed; the boundary must survive a reload (so a resume/replay can tell the @@ -368,6 +403,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) + const repairs: SessionPersistedChange[] = [] + ctx.on('session/persisted', (_header, change) => { + if (change.kind === 'repair') repairs.push(structuredClone(change)) + }) const second = await fix.mount(ctx) // The live session is still the authority: it appends the REAL step/turn // end. Adoption must truncate the torn tail but NOT synthesize closers. @@ -378,6 +417,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(repairs).toEqual([]) await second.dispose() } finally { await ctx.fiber.dispose() @@ -385,6 +425,34 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a query-side load preserves the existing live owner binding', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + let session!: Session + const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } }) + send(session, oneTurnLog()) + }, { inject: ['sessions'] })) + try { + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(session.id) + await liveFiber.dispose() + + let replacement!: Session + await ctx.plugin(Object.assign((inner: Context) => { + replacement = inner.sessions.create(session.id, { + seed: loaded.events, + meta: { cwd: WORK, createdAt: loaded.meta.createdAt }, + }) + }, { inject: ['sessions'] })) + await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + // --- collision / id reuse --- it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { diff --git a/packages/session-query/README.md b/packages/session-query/README.md new file mode 100644 index 0000000000..15e5b13295 --- /dev/null +++ b/packages/session-query/README.md @@ -0,0 +1,9 @@ +# session-query/ — session retrieval capability family + +Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer. + +| Package | Role | ctx key | +|---|---|---| +| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` | + +The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md new file mode 100644 index 0000000000..ab06959ea5 --- /dev/null +++ b/packages/session-query/session-query/README.md @@ -0,0 +1,46 @@ +# @deepseek-ai/dsh-session-query + +Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories. + +This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. + +## Reads and traces + +- `listSessions()` returns cloned lightweight records in deterministic newest-first order. +- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold. +- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50). +- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content. + +An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent. + +## Filters + +`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array. + +Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page. + +## Full-text providers + +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. + +The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. + +Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. + +## Text extractors + +Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. + +Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. | +| `defaultLimit` | `20` | Search page size when the request omits `limit`. | +| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. | +| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | + +The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json new file mode 100644 index 0000000000..a3a3a2839a --- /dev/null +++ b/packages/session-query/session-query/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-session-query", + "description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)", + "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-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts new file mode 100644 index 0000000000..6f8443aeb1 --- /dev/null +++ b/packages/session-query/session-query/src/config.ts @@ -0,0 +1,29 @@ +/** + * Public configuration, defaults, and typed failures for session-query. + * + * @module @deepseek-ai/dsh-session-query/config + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Default page size for provider-backed search. */ +export const SESSION_QUERY_DEFAULT_LIMIT = 20 +/** Maximum page size accepted by provider-backed search. */ +export const SESSION_QUERY_MAX_LIMIT = 100 +/** Default maximum `before`/`after` raw-event window. */ +export const SESSION_QUERY_READ_WINDOW_MAX = 50 + +/** Configuration for the provider-neutral session-query service. */ +export interface Config { + /** Explicit provider id; omitted auto-selects exactly one usable provider. */ + searchProvider?: string + /** Default search result page size. Defaults to 20. */ + defaultLimit?: number + /** Maximum accepted search page size. Defaults to 100. */ + maxLimit?: number + /** Maximum accepted raw read context on either side. Defaults to 50. */ + readWindowMax?: number +} + +/** Typed session-query failure with a stable machine-routable code. */ +export class SessionQueryError extends HarnessError {} diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts new file mode 100644 index 0000000000..980e11e416 --- /dev/null +++ b/packages/session-query/session-query/src/corpus.ts @@ -0,0 +1,221 @@ +/** Live/persisted logical-corpus resolution for session-query. */ + +import type { Context } from 'cordis' +import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import type { SessionRecord } from './types.ts' +import type { LoadedSession } from './extraction.ts' +import { canonicalJson } from './extraction.ts' +import { SessionQueryError } from './config.ts' + +interface PersistenceBinding { + token: symbol + service: SessionPersistence + headers: Map + error?: unknown + refreshing: Promise | undefined +} + +/** Active persistence view used by provider reconciliation. */ +export interface PersistenceView { + /** Canonical headers in deterministic creation order. */ + headers: SessionHeader[] + /** Load one canonical persisted source. */ + load(id: SessionId): Promise +} + +/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */ +export class SessionCorpus { + private _persistence: PersistenceBinding | undefined + + constructor( + private readonly _ctx: Context, + private readonly _onPersistenceChange: (active: boolean) => void, + ) { + _ctx.effect(() => { + const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { + this._attachPersistence(childCtx, childCtx.sessionPersistence) + }) + return () => void fiber.dispose() + }, 'sessionQuery.optionalPersistence') + } + + /** + * List the complete logical corpus with live precedence and cloned headers. + * @returns logical records in deterministic newest-first order. + */ + async listSessions(): Promise { + const binding = await this._ensurePersistence() + const records = new Map() + if (binding !== undefined) { + for (const header of binding.headers.values()) { + records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) + } + } + for (const session of this._ctx.sessions.list()) { + const persisted = binding?.headers.get(session.id) + if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted) + records.set(session.id, { + header: structuredClone(session.header), + live: true, + persisted: persisted !== undefined, + }) + } + return [...records.values()].sort(compareSessions) + } + + /** + * Load one logical source, preferring a detached live snapshot. + * @param sessionId - session to resolve. + * @returns detached live-preferred metadata and events. + */ + async loadLogical(sessionId: SessionId): Promise { + const live = this._ctx.sessions.get(sessionId) + if (live !== undefined) return this.snapshotLive(live) + const binding = await this._ensurePersistence() + if (binding === undefined || !binding.headers.has(sessionId)) { + throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + return this._loadPersisted(binding, sessionId) + } + + /** + * Return a detached live source with current availability flags. + * @param session - live session to snapshot. + * @returns detached metadata and events. + */ + snapshotLive(session: Session): LoadedSession { + const persistedHeader = this._persistence?.headers.get(session.id) + if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader) + return { + record: { + header: structuredClone(session.header), + live: true, + persisted: persistedHeader !== undefined, + }, + events: session.events.map(event => structuredClone(event)), + } + } + + /** + * Get one live session without consulting persistence. + * @param sessionId - live id to resolve. + * @returns current store object, or undefined. + */ + getLive(sessionId: SessionId): Session | undefined { + return this._ctx.sessions.get(sessionId) + } + + /** + * List live sessions in store order. + * @returns fresh array of current store objects. + */ + listLive(): Session[] { + return this._ctx.sessions.list() + } + + /** + * Resolve an authoritative persisted view. + * @returns cloned headers and loader, or undefined while unmounted. + */ + async persistenceView(): Promise { + const binding = await this._ensurePersistence() + if (binding === undefined) return undefined + return { + headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending), + load: id => this._loadPersisted(binding, id), + } + } + + private _attachPersistence(ctx: Context, service: SessionPersistence): void { + const binding: PersistenceBinding = { + token: Symbol('session-query-persistence'), + service, + headers: new Map(), + refreshing: undefined, + } + this._persistence = binding + this._onPersistenceChange(true) + void this._refreshPersistence(binding) + ctx.on('session/persisted', (header) => { + /* v8 ignore next -- a stale notification can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.headers.set(header.id, structuredClone(header)) + this._onPersistenceChange(true) + }) + ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') + } + + private _detachPersistence(binding: PersistenceBinding): void { + /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ + if (this._persistence?.token !== binding.token) return + this._persistence = undefined + this._onPersistenceChange(false) + } + + private _refreshPersistence(binding: PersistenceBinding): Promise { + if (binding.refreshing !== undefined) return binding.refreshing + const refresh = binding.service.list().then((headers) => { + /* v8 ignore next -- a list completion can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)])) + binding.error = undefined + this._onPersistenceChange(true) + }).catch((error: unknown) => { + /* v8 ignore next -- a failed list can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.error = error + }).finally(() => { + /* v8 ignore next -- a newer refresh may already own the slot */ + if (binding.refreshing === refresh) binding.refreshing = undefined + }) + binding.refreshing = refresh + return refresh + } + + private async _ensurePersistence(): Promise { + const binding = this._persistence + if (binding === undefined) return undefined + await this._refreshPersistence(binding) + if (binding.error !== undefined) { + const cause = binding.error + throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause }) + } + return binding + } + + private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise { + try { + const loaded = await binding.service.load(sessionId) + const listed = binding.headers.get(sessionId) + /* v8 ignore else -- every internal persisted load starts from a listed header */ + if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed) + return { + record: { header: structuredClone(loaded.meta), live: false, persisted: true }, + events: loaded.events.map(event => structuredClone(event)), + } + } catch (error: unknown) { + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }) + } + } + + private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { + if (canonicalJson(a) !== canonicalJson(b)) { + throw new SessionQueryError(`live and persisted headers conflict for session "${a.id}"`, 'SESSION_QUERY_SOURCE_CONFLICT') + } + } +} + +function compareSessions(a: SessionRecord, b: SessionRecord): number { + return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) +} + +function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number { + return a.createdAt - b.createdAt || a.id.localeCompare(b.id) +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- persistence service contracts reject Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts new file mode 100644 index 0000000000..58db38c8ef --- /dev/null +++ b/packages/session-query/session-query/src/extraction.ts @@ -0,0 +1,258 @@ +/** Semantic text extraction and stable provider snapshot fingerprints. */ + +import { createHash } from 'node:crypto' +import type { Context } from 'cordis' +import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session' +import type { + SessionContentTextExtractor, + SessionEventTextExtractor, + SessionIndexDocument, + SessionIndexSnapshot, + SessionRecord, +} from './types.ts' +import { SessionQueryError } from './config.ts' +import { eventRecords } from './tracing.ts' + +/** Canonical session source consumed by extraction and provider reconciliation. */ +export interface LoadedSession { + /** Logical source metadata. */ + record: SessionRecord + /** Detached canonical events. */ + events: SessionEvent[] +} + +interface StoredEventExtractor { + version: string + extract(event: SessionEvent): readonly string[] +} + +interface StoredContentExtractor { + version: string + extract(block: ContentBlock): readonly string[] +} + +/** Owns core/custom semantic extractors and builds versioned index snapshots. */ +export class SessionTextExtractors { + private readonly _eventExtractors = new Map() + private readonly _contentExtractors = new Map() + + constructor(private readonly _onChange: () => void) { + this._installCoreExtractors() + } + + /** + * Register one effect-scoped event extractor. + * @param ctx - contributing caller context. + * @param type - event discriminant. + * @param extractor - versioned semantic extractor. + * @returns disposer for the registration. + */ + registerEvent( + ctx: Context, + type: K, + extractor: SessionEventTextExtractor, + ): () => void { + this._validateVersion(type, extractor.version) + if (this._eventExtractors.has(type)) { + throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') + } + const stored: StoredEventExtractor = { + version: extractor.version, + extract: event => extractor.extract(event as SessionEvent), + } + const dispose = ctx.effect(function* (this: SessionTextExtractors) { + this._eventExtractors.set(type, stored) + this._onChange() + yield () => { + this._eventExtractors.delete(type) + this._onChange() + } + }.bind(this), `sessionQuery.eventExtractor(${type})`) + return () => void dispose() + } + + /** + * Register one effect-scoped content-block extractor. + * @param ctx - contributing caller context. + * @param type - content-block discriminant. + * @param extractor - versioned semantic extractor. + * @returns disposer for the registration. + */ + registerContent( + ctx: Context, + type: K, + extractor: SessionContentTextExtractor, + ): () => void { + this._validateVersion(type, extractor.version) + if (this._contentExtractors.has(type)) { + throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') + } + const stored: StoredContentExtractor = { + version: extractor.version, + extract: block => extractor.extract(block as ContentBlockMap[K]), + } + const dispose = ctx.effect(function* (this: SessionTextExtractors) { + this._contentExtractors.set(type, stored) + this._onChange() + yield () => { + this._contentExtractors.delete(type) + this._onChange() + } + }.bind(this), `sessionQuery.contentExtractor(${type})`) + return () => void dispose() + } + + /** + * Build one provider-neutral snapshot and SHA-256 source/version fingerprint. + * @param loaded - detached canonical source. + * @returns lightweight documents and stable fingerprint. + */ + buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot { + const records = eventRecords(loaded.record.header.id, loaded.events) + const documents: SessionIndexDocument[] = [] + const eventVersions = new Set() + const blockVersions = new Set() + for (const event of loaded.events) { + const extractor = this._eventExtractors.get(event.type) + if (extractor === undefined) continue + eventVersions.add(`${event.type}@${extractor.version}`) + collectBlockVersions(event.data, this._contentExtractors, blockVersions) + const text = normalizeText(extractor.extract(event)) + if (text.length === 0) continue + // The event record array parallels the contiguous log. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + documents.push({ ...records[event.seq]!, text }) + } + const fingerprint = createHash('sha256').update(canonicalJson({ + header: loaded.record.header, + events: loaded.events, + eventExtractors: [...eventVersions].sort(), + contentExtractors: [...blockVersions].sort(), + })).digest('hex') + return { + session: cloneRecord(loaded.record), + fingerprint, + documents, + } + } + + private _installCoreExtractors(): void { + this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] }) + this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] }) + this._contentExtractors.set('tool-call', { + version: '1', + extract: (block) => { + const call = block as ContentBlockMap['tool-call'] + return [call.name, call.arguments] + }, + }) + this._contentExtractors.set('tool-result', { + version: '1', + extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content), + }) + for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) { + this._eventExtractors.set(type, { + version: '1', + extract: event => this._extractBlocks((event as SessionEvent).data.content), + }) + } + this._eventExtractors.set('prompt/blocked', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'prompt/blocked'>).data + return [...this._extractBlocks(data.content), data.reason] + }, + }) + this._eventExtractors.set('tool/call', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'tool/call'>).data + return [data.name, data.arguments] + }, + }) + this._eventExtractors.set('tool/result', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'tool/result'>).data + return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? ''] + }, + }) + this._eventExtractors.set('todo/write', { + version: '1', + extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`), + }) + this._eventExtractors.set('turn/end', { + version: '1', + extract: (event) => { + const reason = (event as SessionEvent<'turn/end'>).data.reason + switch (reason.kind) { + case 'error': return ['error', reason.message, reason.code ?? ''] + case 'aborted': return ['aborted', reason.reason ?? ''] + case 'rejected': return ['rejected', reason.reason] + case 'disposed': return ['disposed'] + case 'max-tokens': return ['max-tokens'] + case 'interrupted': return ['interrupted'] + case 'completed': return [] + // TurnEndReasonMap is merge-extensible; unknown variants contribute no text. + /* v8 ignore next -- only an external declaration-merged reason can reach this fallback */ + default: return [] + } + }, + }) + } + + private _extractBlocks(blocks: readonly ContentBlock[]): string[] { + const fragments: string[] = [] + for (const block of blocks) { + const extractor = this._contentExtractors.get(block.type) + if (extractor !== undefined) fragments.push(...extractor.extract(block)) + } + return fragments + } + + private _validateVersion(type: string, version: string): void { + if (version.trim().length === 0) { + throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR') + } + } +} + +/** + * Encode canonical JSON with recursively sorted object keys. + * @param value - JSON-compatible source value. + * @returns deterministic JSON text. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + const object = value as Record + return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}` +} + +function normalizeText(fragments: readonly string[]): string { + return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n') +} + +function collectBlockVersions( + value: unknown, + extractors: ReadonlyMap, + versions: Set, +): void { + if (Array.isArray(value)) { + for (const item of value) collectBlockVersions(item, extractors, versions) + return + } + if (value === null || typeof value !== 'object') return + const object = value as Record + if (typeof object.type === 'string') { + const type = object.type as ContentBlockType + const extractor = extractors.get(type) + if (extractor !== undefined) versions.add(`${type}@${extractor.version}`) + } + for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions) +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts new file mode 100644 index 0000000000..296361cfbf --- /dev/null +++ b/packages/session-query/session-query/src/filters.ts @@ -0,0 +1,123 @@ +/** Pure serializable session-query result filters. */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { + SessionEventRecord, + SessionEventResultFilter, + SessionQueryRange, + SessionRecord, + SessionResultFilter, +} from './types.ts' +import { SessionQueryError } from './config.ts' + +const AVAILABILITIES = ['live', 'persisted'] as const +const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const + +/** + * Apply an ordered AND-chain of session filters while preserving item order + * and the concrete generic item type. + * @param results - session records or richer session search hits. + * @param filters - serializable filters applied in order. + * @returns a fresh filtered array. + */ +export function filterSessionResults( + results: readonly T[], + filters: readonly SessionResultFilter[], +): T[] { + for (const filter of filters) validateSessionFilter(filter) + return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter))) +} + +/** + * Apply an ordered AND-chain of event filters while preserving item order and + * the concrete generic item type. + * @param results - event records or richer event search hits. + * @param filters - serializable filters applied in order. + * @returns a fresh filtered array. + */ +export function filterEventResults( + results: readonly T[], + filters: readonly SessionEventResultFilter[], +): T[] { + for (const filter of filters) validateEventFilter(filter) + return results.filter(result => filters.every(filter => matchesEventFilter(result, filter))) +} + +function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean { + switch (filter.kind) { + case 'id': return filter.values.includes(record.header.id) + case 'cwd': return filter.values.includes(record.header.cwd ?? null) + case 'created-at': return inRange(record.header.createdAt, filter.range) + case 'parent': return filter.values.includes(record.header.parentSession ?? null) + case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted) + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: return assertNever(filter) + } +} + +function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean { + switch (filter.kind) { + case 'seq': return inRange(record.seq, filter.range) + case 'time': return inRange(record.time, filter.range) + case 'type': return filter.values.includes(record.type) + case 'surface': return filter.values.includes(record.surface) + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: return assertNever(filter) + } +} + +function validateSessionFilter(filter: SessionResultFilter): void { + switch (filter.kind) { + case 'id': + case 'cwd': + case 'parent': + return + case 'created-at': + validateRange('created-at', filter.range) + return + case 'availability': + for (const value of filter.values) { + if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`) + } + return + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: + assertNever(filter) + } +} + +function validateEventFilter(filter: SessionEventResultFilter): void { + switch (filter.kind) { + case 'seq': + case 'time': + validateRange(filter.kind, filter.range) + return + case 'type': + return + case 'surface': + for (const value of filter.values) { + if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`) + } + return + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: + assertNever(filter) + } +} + +function validateRange(name: string, range: SessionQueryRange): void { + if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`) + if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + invalidFilter(`${name}.from must be <= ${name}.to`) + } +} + +function invalidFilter(message: string): never { + throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER') +} + +function inRange(value: number, range: SessionQueryRange): boolean { + return (range.from === undefined || value >= range.from) + && (range.to === undefined || value <= range.to) +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts new file mode 100644 index 0000000000..46e3c15c32 --- /dev/null +++ b/packages/session-query/session-query/src/index.ts @@ -0,0 +1,225 @@ +/** + * Provider-neutral session-history retrieval over live and optionally + * persisted session logs. The public service composes logical-corpus reads, + * pure filters and tracing, semantic extraction, and provider coordination. + * + * @module @deepseek-ai/dsh-session-query + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session' +import type { + SessionContentTextExtractor, + SessionEventReadRequest, + SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionEventTextExtractor, + SessionEventTrace, + SessionEventWindow, + SessionLineageTrace, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchRequest, + SessionQueryExecContext, +} from './types.ts' +import { + SESSION_QUERY_DEFAULT_LIMIT, + SESSION_QUERY_MAX_LIMIT, + SESSION_QUERY_READ_WINDOW_MAX, + SessionQueryError, + type Config, +} from './config.ts' +import { SessionTextExtractors } from './extraction.ts' +import { SessionCorpus } from './corpus.ts' +import { SessionProviderCoordinator } from './provider.ts' +import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' + +export type * from './types.ts' +export type { Config } from './config.ts' +export { + SESSION_QUERY_DEFAULT_LIMIT, + SESSION_QUERY_MAX_LIMIT, + SESSION_QUERY_READ_WINDOW_MAX, + SessionQueryError, +} from './config.ts' +export { filterEventResults, filterSessionResults } from './filters.ts' + +declare module 'cordis' { + interface Context { + sessionQuery: SessionQueryService + } +} + +/** Session-history retrieval and provider coordination service. */ +export class SessionQueryService extends Service { + static inject = ['sessions'] + static Config: z = z.object({ + searchProvider: z.string(), + defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT), + readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), + }) + + private readonly _readWindowMax: number + private readonly _extractors: SessionTextExtractors + private readonly _providers: SessionProviderCoordinator + private readonly _corpus: SessionCorpus + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'sessionQuery') + const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT + const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT + this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX + if (defaultLimit > maxLimit) { + throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') + } + this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() }) + this._providers = new SessionProviderCoordinator(ctx, { + ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, + defaultLimit, + maxLimit, + }, () => this._corpus, this._extractors) + this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) }) + } + + /** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ + listSessions(): Promise { + return this._corpus.listSessions() + } + + /** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ + async listEvents(sessionId: SessionId): Promise { + const loaded = await this._corpus.loadLogical(sessionId) + return eventRecords(sessionId, loaded.events) + } + + /** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ + async readEvent(request: SessionEventReadRequest): Promise { + const before = this._readWindow('before', request.before) + const after = this._readWindow('after', request.after) + const loaded = await this._corpus.loadLogical(request.sessionId) + const target = loaded.events[request.seq] + if (target === undefined || target.seq !== request.seq) { + throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + } + const startSeq = Math.max(0, request.seq - before) + const endSeq = Math.min(loaded.events.length - 1, request.seq + after) + return { + session: cloneRecord(loaded.record), + target: structuredClone(target), + events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)), + startSeq, + endSeq, + } + } + + /** + * Trace parent ancestry and the complete known descendant tree of a session. + * @param sessionId - logical session id to trace. + * @returns complete or explicitly partial lineage. + */ + async traceSession(sessionId: SessionId): Promise { + return traceLineage(await this._corpus.listSessions(), sessionId) + } + + /** + * Trace direct provenance and surface replacement relationships for any event. + * @param sessionId - logical session containing the target. + * @param seq - target event seq. + * @returns lightweight trace with related seq links. + */ + async traceEvent(sessionId: SessionId, seq: number): Promise { + return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq) + } + + /** + * Register one full-text provider with effect-scoped disposal. + * @param provider - provider and synchronization implementation. + * @returns disposer that unregisters the provider. + */ + registerSearchProvider(provider: SessionSearchProvider): () => void { + return this._providers.register(this.ctx, provider) + } + + /** + * Register semantic text extraction for one event type. + * @param type - declaration-merged event discriminant. + * @param extractor - stable version and typed extraction callback. + * @returns disposer that removes the extractor. + */ + registerEventTextExtractor( + type: K, + extractor: SessionEventTextExtractor, + ): () => void { + return this._extractors.registerEvent(this.ctx, type, extractor) + } + + /** + * Register semantic text extraction for one content block type. + * @param type - declaration-merged content-block discriminant. + * @param extractor - stable version and typed extraction callback. + * @returns disposer that removes the extractor. + */ + registerContentTextExtractor( + type: K, + extractor: SessionContentTextExtractor, + ): () => void { + return this._extractors.registerContent(this.ctx, type, extractor) + } + + /** + * Search the complete logical corpus and rank one result per session. + * @param request - query, pre-ranking filters, and pagination. + * @param exec - optional cancellation context. + * @returns ranked provider page. + */ + searchSessions( + request: SessionSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + return this._providers.searchSessions(request, exec) + } + + /** + * Search events within one logical session. + * @param request - target session, query, filters, and pagination. + * @param exec - optional cancellation context. + * @returns ranked provider page. + */ + searchEvents( + request: SessionEventSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + return this._providers.searchEvents(request, exec) + } + + private _readWindow(name: 'before' | 'after', value: number | undefined): number { + if (value === undefined) return 0 + if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) { + throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW') + } + return value + } +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} + +export default SessionQueryService diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts new file mode 100644 index 0000000000..7000c0e6d1 --- /dev/null +++ b/packages/session-query/session-query/src/provider.ts @@ -0,0 +1,328 @@ +/** Search-provider selection, synchronization, pagination, and cancellation. */ + +import type { Context } from 'cordis' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionTextExtractors } from './extraction.ts' +import type { PersistenceView, SessionCorpus } from './corpus.ts' +import type { + SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionQueryExecContext, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchRequest, +} from './types.ts' +import type { Config } from './config.ts' +import { SessionQueryError } from './config.ts' +import { filterEventResults, filterSessionResults } from './filters.ts' + +interface ProviderState { + provider: SessionSearchProvider + active: boolean + chain: Promise + liveIds: Set + fullSync: Promise | undefined + liveSync: Map> +} + +type NormalizedSessionSearchRequest = SessionSearchRequest & { limit: number } +type NormalizedEventSearchRequest = SessionEventSearchRequest & { limit: number } + +/** Coordinates one selected provider against live and persisted corpus layers. */ +export class SessionProviderCoordinator { + private readonly _configuredProviderId: string | undefined + private readonly _defaultLimit: number + private readonly _maxLimit: number + private readonly _providers = new Map() + + constructor( + private readonly _ctx: Context, + config: Required> & Pick, + private readonly _corpus: () => SessionCorpus, + private readonly _extractors: SessionTextExtractors, + ) { + this._configuredProviderId = config.searchProvider + this._defaultLimit = config.defaultLimit + this._maxLimit = config.maxLimit + _ctx.on('session/created', (session) => { this.invalidateLive(session.id) }) + _ctx.on('session/event', (session) => { this.invalidateLive(session.id) }) + _ctx.on('session/removed', (header) => { this.invalidateLive(header.id) }) + } + + /** + * Register one effect-scoped provider. + * @param ctx - contributing caller context. + * @param provider - provider implementation. + * @returns disposer for the registration. + */ + register(ctx: Context, provider: SessionSearchProvider): () => void { + if (this._providers.has(provider.id)) { + throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') + } + const state: ProviderState = { + provider, + active: true, + chain: Promise.resolve(), + liveIds: new Set(), + fullSync: undefined, + liveSync: new Map(), + } + const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { + this._providers.set(provider.id, state) + void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => { + this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`) + }) + yield () => { + state.active = false + this._providers.delete(provider.id) + } + }.bind(this), 'sessionQuery.registerSearchProvider()') + return () => void dispose() + } + + /** + * Search and group the complete logical corpus. + * @param request - normalized provider-neutral request input. + * @param exec - optional cancellation controls. + * @returns ranked session page. + */ + async searchSessions( + request: SessionSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + const state = this._resolveProvider() + const normalized = this._normalizeSessionSearch(request) + await waitFor(this._syncAll(state), exec?.signal) + const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal) + return this._validateSearchPage(state, result, normalized.limit) + } + + /** + * Search events within one logical session. + * @param request - target and provider-neutral request input. + * @param exec - optional cancellation controls. + * @returns ranked event page. + */ + async searchEvents( + request: SessionEventSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + const state = this._resolveProvider() + const normalized = this._normalizeEventSearch(request) + const live = this._corpus().getLive(request.sessionId) + if (live !== undefined) { + await waitFor(this._syncLive(state, live), exec?.signal) + } else { + const persistence = await this._corpus().persistenceView() + if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { + throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + await waitFor(this._syncAll(state), exec?.signal) + } + const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal) + return this._validateSearchPage(state, result, normalized.limit) + } + + /** + * Invalidate provider synchronization after one live source change. + * @param sessionId - changed live session. + */ + invalidateLive(sessionId: SessionId): void { + for (const state of this._providers.values()) { + state.fullSync = undefined + state.liveSync.delete(sessionId) + } + } + + /** Invalidate all source/extractor-derived provider snapshots. */ + invalidateAll(): void { + for (const state of this._providers.values()) { + state.fullSync = undefined + state.liveSync.clear() + } + } + + /** + * React to persistence mount, inventory change, or unmount. + * @param active - whether canonical persistence remains mounted. + */ + persistenceChanged(active: boolean): void { + for (const state of this._providers.values()) state.fullSync = undefined + if (active) return + for (const state of this._providers.values()) { + void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => { + this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`) + }) + } + } + + private _syncAll(state: ProviderState): Promise { + if (state.fullSync !== undefined) return state.fullSync + const promise = this._enqueue(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + const persistence = await this._corpus().persistenceView() + if (persistence === undefined) { + await state.provider.setPersistedActive(false) + } else { + await this._syncPersisted(state, persistence) + } + await this._replaceLiveCorpus(state, this._corpus().listLive()) + }) + state.fullSync = promise + void promise.finally(() => { + /* v8 ignore next -- a newer invalidation may already own the sync slot */ + if (state.fullSync === promise) state.fullSync = undefined + }).catch(() => undefined) + return promise + } + + private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { + await state.provider.setPersistedActive(false) + const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint])) + for (const header of persistence.headers) { + const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id)) + if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot) + inventory.delete(header.id) + } + for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId) + await state.provider.setPersistedActive(true) + } + + private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise { + const liveIds = new Set(sessions.map(session => session.id)) + for (const staleId of state.liveIds) { + if (!liveIds.has(staleId)) await state.provider.removeLive(staleId) + } + for (const session of sessions) { + await state.provider.replaceLive(this._snapshotLive(session)) + } + state.liveIds = liveIds + } + + private _syncLive(state: ProviderState, session: Session): Promise { + const existing = state.liveSync.get(session.id) + if (existing !== undefined) return existing + const snapshot = this._snapshotLive(session) + const promise = this._enqueue(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + await state.provider.replaceLive(snapshot) + state.liveIds.add(session.id) + }) + state.liveSync.set(session.id, promise) + void promise.finally(() => { + /* v8 ignore next -- a newer invalidation may already own the target slot */ + if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id) + }).catch(() => undefined) + return promise + } + + private _snapshotLive(session: Session): ReturnType { + return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) + } + + private _enqueue(state: ProviderState, operation: () => Promise): Promise { + const next = state.chain.then(operation, operation) + state.chain = next.then(() => undefined, () => undefined) + return next.catch((error: unknown) => { + /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + }) + } + + private _resolveProvider(): ProviderState { + if (this._configuredProviderId !== undefined) { + const state = this._providers.get(this._configuredProviderId) + if (state === undefined) { + throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING') + } + if (!state.provider.status().available) { + throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE') + } + return state + } + const usable = [...this._providers.values()].filter(state => state.provider.status().available) + const [single] = usable + if (single === undefined) { + throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE') + } + if (usable.length > 1) { + throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS') + } + return single + } + + private _normalizeSessionSearch(request: SessionSearchRequest): NormalizedSessionSearchRequest { + const query = this._queryText(request.query) + const limit = this._limitValue(request.limit) + filterSessionResults([], request.sessionFilters ?? []) + filterEventResults([], request.eventFilters ?? []) + return { ...request, query, limit } + } + + private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest { + const query = this._queryText(request.query) + const limit = this._limitValue(request.limit) + filterEventResults([], request.filters ?? []) + return { ...request, query, limit } + } + + private _queryText(query: string): string { + const normalized = query.trim() + if (normalized.length === 0) { + throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY') + } + return normalized + } + + private _limitValue(limit: number | undefined): number { + const value = limit ?? this._defaultLimit + if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) { + throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT') + } + return value + } + + private _validateSearchPage(state: ProviderState, page: SessionSearchPage, limit: number): SessionSearchPage { + if (page.providerId !== state.provider.id) { + throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') + } + return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) } + } +} + +function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return work + if (signal.aborted) return Promise.reject(aborted()) + return new Promise((resolve, reject) => { + const onAbort = () => { reject(aborted()) } + signal.addEventListener('abort', onAbort, { once: true }) + work.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + /* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */ + reject(error instanceof Error + ? error + : new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error })) + }, + ) + }) +} + +function aborted(): SessionQueryError { + return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- provider update contracts reject Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts new file mode 100644 index 0000000000..61363e8254 --- /dev/null +++ b/packages/session-query/session-query/src/tracing.ts @@ -0,0 +1,158 @@ +/** Session lineage and event surface/provenance tracing. */ + +import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { + SessionEventRecord, + SessionEventTrace, + SessionLineageNode, + SessionLineageTrace, + SessionRecord, +} from './types.ts' +import { SessionQueryError } from './config.ts' + +/** + * Classify raw events against the canonical surface fold. + * @param sessionId - owner of the event log. + * @param events - detached raw log. + * @returns lightweight records in seq order. + */ +export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { + const fold = safeFold(events) + const current = new Set(fold.nodes.map(node => node.seq)) + const shadowed = new Set(fold.replacements.flatMap(replacement => replacement.shadowedSeqs)) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + })) +} + +/** + * Build one event trace from a validated logical event log. + * @param sessionId - owner of the event log. + * @param events - detached raw log. + * @param seq - target event seq. + * @returns direct provenance and replacement relationships. + */ +export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace { + const target = events[seq] + if (target === undefined || target.seq !== seq) { + throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + } + const records = eventRecords(sessionId, events) + const fold = safeFold(events) + const shadowedBy = new Map() + const shadows = new Map() + for (const replacement of fold.replacements) { + shadows.set(replacement.seq, [...replacement.shadowedSeqs]) + for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq) + } + const references: number[] = [] + const referencedBy: number[] = [] + for (const event of events) { + if (!isSurfaceEvent(event)) continue + for (const source of event.sourceEventSeqs ?? []) { + if (event.seq === seq) references.push(source) + if (source === seq) referencedBy.push(event.seq) + } + } + const replacementChain: number[] = [] + let replacement = shadowedBy.get(seq) + while (replacement !== undefined) { + replacementChain.push(replacement) + replacement = shadowedBy.get(replacement) + } + const immediate = shadowedBy.get(seq) + // seq was checked against the contiguous event log, so its parallel record exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const targetRecord = records[seq]! + return { + target: { ...targetRecord }, + ...immediate !== undefined ? { shadowedBy: immediate } : {}, + replacementChain, + shadows: shadows.get(seq) ?? [], + references, + referencedBy, + } +} + +/** + * Trace ancestry and descendants within one materialized logical corpus. + * @param records - complete visible logical corpus. + * @param sessionId - target session id. + * @returns complete known lineage or explicit unresolved parent. + */ +export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace { + const byId = new Map(records.map(record => [record.header.id, record])) + const target = byId.get(sessionId) + if (target === undefined) { + throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + + const parents: SessionRecord[] = [] + const ancestrySeen = new Set([sessionId]) + let unresolvedParentId: SessionId | undefined + let parentId = target.header.parentSession + while (parentId !== undefined) { + if (ancestrySeen.has(parentId)) lineageCycle(parentId) + ancestrySeen.add(parentId) + const parent = byId.get(parentId) + if (parent === undefined) { + unresolvedParentId = parentId + break + } + parents.push(parent) + parentId = parent.header.parentSession + } + + const childrenByParent = new Map() + for (const record of records) { + const parent = record.header.parentSession + if (parent === undefined) continue + const children = childrenByParent.get(parent) ?? [] + children.push(record) + childrenByParent.set(parent, children) + } + for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) + const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({ + session: cloneRecord(child), + children: buildChildren(child.header.id), + })) + + return { + target: cloneRecord(target), + parents: parents.map(cloneRecord), + ...unresolvedParentId !== undefined + ? { unresolvedParentId } + : { root: cloneRecord(parents.at(-1) ?? target) }, + children: buildChildren(sessionId), + } +} + +function safeFold(events: readonly SessionEvent[]): ReturnType { + try { + return foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error }) + } +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} + +function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { + return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) +} + +function lineageCycle(id: SessionId): never { + throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE') +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- foldSurface throws Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts new file mode 100644 index 0000000000..d3e819cf1a --- /dev/null +++ b/packages/session-query/session-query/src/types.ts @@ -0,0 +1,292 @@ +/** + * Public vocabulary for the session-query retrieval service: lightweight + * records, composable filters, traces, search requests/results, extractor + * registrations, and the provider synchronization contract. + * + * @module @deepseek-ai/dsh-session-query/types + */ + +import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { + SessionEvent, + SessionEventType, + SessionHeader, + SessionId, +} from '@deepseek-ai/dsh-session' + +/** Whether an event is on the current surface, was replaced, or is log-only. */ +export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' + +/** Lightweight identity and availability for one logical session. */ +export interface SessionRecord { + /** Cloned immutable session header selected from the live-preferred corpus. */ + header: SessionHeader + /** Whether the id currently exists in `ctx.sessions`. */ + live: boolean + /** Whether the active persistence backend currently materializes the id. */ + persisted: boolean +} +/** Lightweight metadata for one event within a logical session. */ +export interface SessionEventRecord { + /** Session that owns the event. */ + sessionId: SessionId + /** Monotonic event seq within the session. */ + seq: number + /** Discriminant of the session event. */ + type: SessionEventType + /** Event timestamp in Unix epoch milliseconds. */ + time: number + /** Event placement in the folded session surface. */ + surface: SessionEventSurface +} + +/** Inclusive numeric range used by result and search filters. */ +export interface SessionQueryRange { + /** Inclusive lower bound. */ + from?: number + /** Inclusive upper bound. */ + to?: number +} + +/** Serializable filter applied to session records. */ +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | { kind: 'created-at'; range: SessionQueryRange } + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } + +/** Serializable filter applied to event records. */ +export type SessionEventResultFilter = + | { kind: 'seq'; range: SessionQueryRange } + | { kind: 'time'; range: SessionQueryRange } + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + +/** Caller cancellation threaded through synchronization and provider search. */ +export interface SessionQueryExecContext { + /** Abort signal for waiting and provider-owned query work. */ + readonly signal?: AbortSignal +} + +/** Cheap local usability status returned by a search provider. */ +export type SessionSearchProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } + +/** Common pagination fields accepted by both search scopes. */ +export interface SessionSearchPageRequest { + /** Maximum number of hits on this page. */ + limit?: number + /** Opaque cursor returned by the same provider/request. */ + cursor?: string +} + +/** Cross-session full-text request. */ +export interface SessionSearchRequest extends SessionSearchPageRequest { + /** Plain text query interpreted by the selected provider. */ + query: string + /** Session metadata filters applied before event ranking/grouping. */ + sessionFilters?: readonly SessionResultFilter[] + /** Event metadata filters applied before best-event grouping. */ + eventFilters?: readonly SessionEventResultFilter[] +} + +/** Full-text request scoped to one session's events. */ +export interface SessionEventSearchRequest extends SessionSearchPageRequest { + /** Session whose events form the search corpus. */ + sessionId: SessionId + /** Plain text query interpreted by the selected provider. */ + query: string + /** Event metadata filters applied before ranking. */ + filters?: readonly SessionEventResultFilter[] +} + +/** One lightweight event search hit with provider-produced evidence text. */ +export interface SessionEventSearchHit extends SessionEventRecord { + /** Plain-text excerpt explaining the match. */ + snippet: string +} + +/** One session-ranked search hit and its strongest matching event. */ +export interface SessionSearchHit extends SessionRecord { + /** Strongest matching event used as the session's ranking evidence. */ + bestMatch: SessionEventSearchHit +} + +/** One provider-owned page of search results. */ +export interface SessionSearchPage { + /** Stable id of the provider that produced this page. */ + providerId: string + /** Ranked hits in deterministic provider order. */ + items: readonly T[] + /** Opaque next-page cursor, absent when the result is exhausted. */ + nextCursor?: string +} + +/** Request for one event plus raw neighboring log context. */ +export interface SessionEventReadRequest { + /** Session that owns the target event. */ + sessionId: SessionId + /** Target event seq. */ + seq: number + /** Number of preceding raw events to include. */ + before?: number + /** Number of following raw events to include. */ + after?: number +} + +/** Full target event and a bounded raw-log window. */ +export interface SessionEventWindow { + /** Logical session metadata at read time. */ + session: SessionRecord + /** Full cloned target event. */ + target: SessionEvent + /** Full cloned events from `startSeq` through `endSeq`. */ + events: SessionEvent[] + /** First seq included in `events`. */ + startSeq: number + /** Last seq included in `events`. */ + endSeq: number +} + +/** Recursive child node in a session lineage trace. */ +export interface SessionLineageNode { + /** Session represented by this lineage node. */ + session: SessionRecord + /** Direct children in deterministic creation order. */ + children: SessionLineageNode[] +} + +/** Complete known lineage around one session. */ +export interface SessionLineageTrace { + /** Session that was traced. */ + target: SessionRecord + /** Known parents from immediate parent outward. */ + parents: SessionRecord[] + /** Root when the complete parent chain is available. */ + root?: SessionRecord + /** First parent id outside the visible corpus, when the trace is partial. */ + unresolvedParentId?: SessionId + /** Complete known descendant forest rooted at the target's direct children. */ + children: SessionLineageNode[] +} + +/** Surface and provenance relationships for one event. */ +export interface SessionEventTrace { + /** Lightweight target record. */ + target: SessionEventRecord + /** Immediate replacement event that shadowed the target. */ + shadowedBy?: number + /** Replacement seqs from the target toward the current descendant. */ + replacementChain: number[] + /** Surface nodes directly shadowed by the target replacement event. */ + shadows: number[] + /** Direct provenance sources from `sourceEventSeqs`. */ + references: number[] + /** Events that directly name the target in `sourceEventSeqs`. */ + referencedBy: number[] +} + +/** Typed extractor for one declaration-merged session event type. */ +export interface SessionEventTextExtractor { + /** Stable cache-invalidation version chosen by the extractor owner. */ + version: string + /** + * Extract semantic searchable fragments from one event. + * @param event - event narrowed to the registered type. + * @returns plain-text fragments; blanks are discarded by the service. + */ + extract(event: SessionEvent): readonly string[] +} + +/** Typed extractor for one declaration-merged content block type. */ +export interface SessionContentTextExtractor { + /** Stable cache-invalidation version chosen by the extractor owner. */ + version: string + /** + * Extract semantic searchable fragments from one content block. + * @param block - block narrowed to the registered type. + * @returns plain-text fragments; blanks are discarded by the service. + */ + extract(block: ContentBlockMap[K]): readonly string[] +} + +/** One provider-neutral event document produced by registered extractors. */ +export interface SessionIndexDocument extends SessionEventRecord { + /** Normalized newline-joined text indexed by a search provider. */ + text: string +} + +/** One complete index layer for a live session or persisted checkpoint. */ +export interface SessionIndexSnapshot { + /** Layer metadata and live/persisted availability exposed in results. */ + session: SessionRecord + /** Stable SHA-256 identity of canonical source data and extractor versions. */ + fingerprint: string + /** Searchable event documents in seq order. */ + documents: readonly SessionIndexDocument[] +} + +/** Durable provider inventory entry used to reuse unchanged persisted rows. */ +export interface SessionPersistedIndexEntry { + /** Persisted session id. */ + sessionId: SessionId + /** Last indexed source/extractor fingerprint. */ + fingerprint: string +} + +/** Search and synchronization backend registered into `ctx.sessionQuery`. */ +export interface SessionSearchProvider { + /** Stable provider id, unique within the query service. */ + readonly id: string + /** + * Return cheap local usability without performing index or search I/O. + * @returns whether the provider can be selected. + */ + status(): SessionSearchProviderStatus + /** + * Read reusable persisted-layer fingerprints from derived storage. + * @returns durable inventory entries. + */ + persistedInventory(): Promise + /** + * Hide or expose reconciled persisted rows without deleting their cache. + * @param active - whether canonical persistence is mounted and reconciled. + */ + setPersistedActive(active: boolean): Promise + /** + * Atomically replace one persisted session's derived documents. + * @param snapshot - canonical persisted checkpoint and fingerprint. + */ + replacePersisted(snapshot: SessionIndexSnapshot): Promise + /** + * Delete one durable derived entry after canonical reconciliation proves it absent. + * @param sessionId - persisted id to remove. + */ + removePersisted(sessionId: SessionId): Promise + /** + * Replace one connection-local live override. + * @param snapshot - current live snapshot and availability. + */ + replaceLive(snapshot: SessionIndexSnapshot): Promise + /** + * Drop one live override, revealing its active persisted base when present. + * @param sessionId - live id to remove. + */ + removeLive(sessionId: SessionId): Promise + /** + * Search and group the complete logical corpus by session. + * @param request - query, pre-ranking filters, and pagination. + * @param exec - optional cancellation context. + * @returns one ranked session page. + */ + searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + /** + * Search events within one logical session. + * @param request - target session, query, filters, and pagination. + * @param exec - optional cancellation context. + * @returns one ranked event page. + */ + searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> +} diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts new file mode 100644 index 0000000000..88fced88d3 --- /dev/null +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -0,0 +1,704 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionQueryService, { + SessionQueryError, + filterEventResults, + filterSessionResults, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventSearchHit, + SessionEventSearchRequest, + SessionIndexSnapshot, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchProviderStatus, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +declare module '@deepseek-ai/dsh-llm' { + interface ContentBlockMap { + 'test/text': { type: 'test/text'; value: string } + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'test/note': { note: string } + } +} + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function eventLog(text = 'hello'): SessionEvent[] { + return [{ + type: 'user/message', + seq: 0, + time: 10, + data: { content: [{ type: 'text', text }], source: { kind: 'user' } }, + surfaceOp: 'append', + }] +} + +class TestPersistence extends SessionPersistence { + static entries = new Map() + static listFailure: unknown + static loadFailure: unknown + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listFailure = undefined + this.loadFailure = undefined + } + + create(meta: SessionHeader): Promise { + TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing test session') + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure)) + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + return Promise.resolve(structuredClone(entry)) + } + + list(): Promise { + if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) + return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))) + } +} + +class FakeProvider implements SessionSearchProvider { + readonly id: string + statusValue: SessionSearchProviderStatus = { available: true } + persisted = new Map() + live = new Map() + activeHistory: boolean[] = [] + removedPersisted: SessionIdType[] = [] + removedLive: SessionIdType[] = [] + sessionRequests: SessionSearchRequest[] = [] + eventRequests: SessionEventSearchRequest[] = [] + failNextLive = false + failNextPersisted = false + failNextActive = false + sessionPage: SessionSearchPage + eventPage: SessionSearchPage + + constructor(id = 'fake') { + this.id = id + this.sessionPage = { providerId: id, items: [] } + this.eventPage = { providerId: id, items: [] } + } + + status(): SessionSearchProviderStatus { + return this.statusValue + } + + persistedInventory(): Promise { + return Promise.resolve([...this.persisted.values()].map(snapshot => ({ + sessionId: snapshot.session.header.id, + fingerprint: snapshot.fingerprint, + }))) + } + + setPersistedActive(active: boolean): Promise { + if (this.failNextActive) { + this.failNextActive = false + return Promise.reject(new Error('activation failed')) + } + this.activeHistory.push(active) + return Promise.resolve() + } + + replacePersisted(snapshot: SessionIndexSnapshot): Promise { + if (this.failNextPersisted) { + this.failNextPersisted = false + return Promise.reject(new Error('persisted index failed')) + } + this.persisted.set(snapshot.session.header.id, structuredClone(snapshot)) + return Promise.resolve() + } + + removePersisted(sessionId: SessionIdType): Promise { + this.removedPersisted.push(sessionId) + this.persisted.delete(sessionId) + return Promise.resolve() + } + + replaceLive(snapshot: SessionIndexSnapshot): Promise { + if (this.failNextLive) { + this.failNextLive = false + return Promise.reject(new Error('live index failed')) + } + this.live.set(snapshot.session.header.id, structuredClone(snapshot)) + return Promise.resolve() + } + + removeLive(sessionId: SessionIdType): Promise { + this.removedLive.push(sessionId) + this.live.delete(sessionId) + return Promise.resolve() + } + + searchSessions(request: SessionSearchRequest): Promise> { + this.sessionRequests.push(structuredClone(request)) + return Promise.resolve(structuredClone(this.sessionPage)) + } + + searchEvents(request: SessionEventSearchRequest): Promise> { + this.eventRequests.push(structuredClone(request)) + return Promise.resolve(structuredClone(this.eventPage)) + } +} + +async function liveContext(config: ConstructorParameters[1] = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService, config) + return ctx +} + +function expectCode(code: string): Error { + return expect.objectContaining({ code }) as Error +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +describe('pure result filters', () => { + it('chains session filters as AND while values within one filter are OR', () => { + const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } + const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true } + const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true } + const input = [child, root, both] + + const output = filterSessionResults(input, [ + { kind: 'cwd', values: ['/a', '/b'] }, + { kind: 'created-at', range: { from: 2, to: 3 } }, + { kind: 'parent', values: [root.header.id] }, + { kind: 'availability', values: ['live', 'persisted'] }, + { kind: 'id', values: [child.header.id, both.header.id] }, + ]) + + expect(output).toEqual([child, both]) + expect(output[0]).toBe(child) + expect(input).toEqual([child, root, both]) + expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([]) + }) + + it('filters event ranges/types/status without reordering richer records', () => { + const events = [ + { sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true }, + { sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true }, + { sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true }, + ] + const output = filterEventResults(events, [ + { kind: 'seq', range: { from: 1, to: 2 } }, + { kind: 'time', range: { from: 10, to: 20 } }, + { kind: 'type', values: ['user/message', 'tool/call'] }, + { kind: 'surface', values: ['current', 'shadowed'] }, + ]) + expect(output).toEqual(events.slice(0, 2)) + expect(output[0]).toBe(events[0]) + }) + + it('rejects invalid serializable filter values', () => { + expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('handles absent range bounds and root/availability alternatives', () => { + const record: SessionRecord = { header: header('root'), live: false, persisted: true } + expect(filterSessionResults([record], [ + { kind: 'parent', values: [null] }, + { kind: 'cwd', values: [null] }, + { kind: 'availability', values: ['persisted'] }, + ])).toEqual([record]) + const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const } + expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event]) + }) +}) + +describe('logical corpus reads and traces', () => { + it('lists, classifies, reads, and traces a live session using detached records', async () => { + const ctx = await liveContext({ readWindowMax: 2 }) + const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } }) + const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } }) + const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] }) + const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] }) + const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] }) + + const listed = await ctx.sessionQuery.listSessions() + expect(listed).toEqual([{ header: session.header, live: true, persisted: false }]) + listed[0]!.header.createdAt = -1 + expect(session.header.createdAt).toBe(20) + expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface)) + .toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current']) + + const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 }) + expect([window.startSeq, window.endSeq]).toEqual([0, 4]) + expect(window.target.seq).toBe(answer.seq) + if (window.events[0]?.type !== 'user/message') throw new Error('expected user message') + window.events[0].data.content = [] + expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) + + await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({ + shadowedBy: summary.seq, + replacementChain: [summary.seq, resummary.seq], + referencedBy: [summary.seq], + }) + await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({ + shadows: [original.seq], + references: [original.seq], + referencedBy: [resummary.seq], + }) + await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] }) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + }) + + it('turns malformed replacement logs into typed surface failures', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('bad-surface')) + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { + surfaceOp: { op: 'replace', start: 9, end: 9 }, + sourceEventSeqs: [], + }) + await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('returns complete, partial, deterministic, and cycle-checked lineage', async () => { + const ctx = await liveContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) + const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } }) + const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } }) + const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } }) + const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } }) + + const trace = await ctx.sessionQuery.traceSession(grandchild.id) + expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id]) + expect(trace.root?.header.id).toBe(root.id) + const rootTrace = await ctx.sessionQuery.traceSession(root.id) + expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id]) + expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id) + await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') }) + await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + const cyclic = await liveContext() + const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') })) + const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') })) + cyclic.sessions.enter(a) + cyclic.sessions.enter(b) + await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) + }) + + it('uses live content over a matching persisted base and scopes persistence failures', async () => { + const common = header('same', 5, { cwd: '/w' }) + const persistedOnly = header('persisted', 1) + TestPersistence.reset([ + { meta: common, events: eventLog('persisted version') }, + { meta: persistedOnly, events: eventLog('persisted only') }, + ]) + const ctx = await liveContext() + const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } }) + live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const persistenceFiber = await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listEvents(SessionId('not-listed'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + const records = await ctx.sessionQuery.listSessions() + expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([ + [common.id, true, true], + [persistedOnly.id, false, true], + ]) + const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 }) + expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' }) + await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 })) + .resolves.toMatchObject({ session: { persisted: true } }) + + TestPersistence.listFailure = new Error('list unavailable') + await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.listFailure = undefined + TestPersistence.loadFailure = new Error('load unavailable') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE')) + + await persistenceFiber.dispose() + TestPersistence.loadFailure = undefined + await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }]) + }) + + it('rejects immutable source header conflicts', async () => { + TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }]) + const ctx = await liveContext() + ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } }) + await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) +}) + +describe('provider selection and synchronization', () => { + it('selects one usable provider, validates requests/pages, and disposes registration', async () => { + const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 }) + const session = ctx.sessions.create(SessionId('s')) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const dispose = ctx.sessionQuery.registerSearchProvider(provider) + const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false } + const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' } + provider.sessionPage = { providerId: provider.id, items: [ + { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, + ], nextCursor: 'next' } + + const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] }) + expect(page.items).toHaveLength(2) + expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) + expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') + await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) + provider.eventPage = { providerId: 'wrong', items: [] } + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) + + provider.eventPage = { providerId: provider.id, items: [] } + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) + .resolves.toMatchObject({ providerId: provider.id }) + + dispose() + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + }) + + it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('coalesce')) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + let releaseLive!: () => void + const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) + let replacements = 0 + provider.replaceLive = async (snapshot) => { + replacements += 1 + await liveBarrier + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + await Promise.resolve() + releaseLive() + await Promise.all([first, second]) + expect(replacements).toBe(1) + + session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + let releaseCorpus!: () => void + const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) + let corpusReplacements = 0 + provider.replaceLive = async (snapshot) => { + corpusReplacements += 1 + await corpusBarrier + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) + const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) + await Promise.resolve() + releaseCorpus() + await Promise.all([crossFirst, crossSecond]) + expect(corpusReplacements).toBe(1) + + let releaseSearch!: () => void + const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) + provider.searchSessions = async () => { + await searchBarrier + return { providerId: provider.id, items: [] } + } + const controller = new AbortController() + const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal }) + await Promise.resolve() + await Promise.resolve() + controller.abort() + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + releaseSearch() + await Promise.resolve() + + provider.searchSessions = () => Promise.reject(new Error('search failed')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal })) + .rejects.toThrow('search failed') + }) + + it('searches a persisted target after corpus reconciliation', async () => { + const persisted = header('event-persisted', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' })) + .resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') + }) + + it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { + const ctx = await liveContext() + const first = new FakeProvider('first') + ctx.sessionQuery.registerSearchProvider(first) + expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER')) + const second = new FakeProvider('second') + ctx.sessionQuery.registerSearchProvider(second) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS')) + + const configured = await liveContext({ searchProvider: 'chosen' }) + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')) + const chosen = new FakeProvider('chosen') + chosen.statusValue = { available: false, reason: 'unavailable' } + configured.sessionQuery.registerSearchProvider(chosen) + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')) + chosen.statusValue = { available: true } + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' }) + }) + + it('removes provider registrations with their contributing fiber', async () => { + const ctx = await liveContext() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped')) + }, { inject: ['sessionQuery'] })) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' }) + await fiber.dispose() + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + }) + + it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => { + const persisted = header('persisted', 1) + const overlaid = header('overlaid', 1) + TestPersistence.reset([ + { meta: persisted, events: eventLog('persisted') }, + { meta: overlaid, events: eventLog('base') }, + ]) + const ctx = await liveContext() + const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } }) + live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const persistenceFiber = await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + provider.failNextActive = true + provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') + expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') + expect(provider.removedPersisted).toEqual([SessionId('stale')]) + expect(provider.activeHistory.at(-1)).toBe(true) + const fingerprint = provider.persisted.get(persisted.id)?.fingerprint + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint) + + const announced = header('announced', 3) + TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') }) + await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') + + provider.failNextActive = true + await persistenceFiber.dispose() + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.activeHistory.at(-1)).toBe(false) + expect(provider.persisted.has(persisted.id)).toBe(true) + }) + + it('synchronizes only a live target for event search and retries dirty failures', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('target')) + session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' }) + expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one') + session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + provider.failNextLive = true + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two']) + + const controller = new AbortController() + controller.abort() + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('removes a disposed live override and reveals the provider base', async () => { + const persisted = header('fallback', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('base') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + let session!: Session + const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } }) + session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + }, { inject: ['sessions'] })) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.live.has(session.id)).toBe(true) + + await liveFiber.dispose() + await Promise.resolve() + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.removedLive).toContain(session.id) + expect(provider.live.has(session.id)).toBe(false) + expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base') + }) + + it('retries failed persisted reconciliation without affecting canonical writes', async () => { + const persisted = header('retry', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + provider.failNextPersisted = true + ctx.sessionQuery.registerSearchProvider(provider) + + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') + }) +}) + +describe('semantic text extractors', () => { + it('indexes core semantic text and excludes chunks and structural events', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('semantic')) + const nested: ContentBlock[] = [ + { type: 'text', text: 'visible' }, + { type: 'reasoning', text: 'thinking' }, + { type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' }, + { type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] }, + ] + session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' }) + session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }) + session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } }) + session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } }) + session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } }) + session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } }) + session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) + session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } }) + session.append('turn/end', { turn: 8, reason: { kind: 'completed' } }) + session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } }) + session.append('step/start', { turn: 1, step: 2 }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const documents = provider.live.get(session.id)?.documents ?? [] + expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result') + expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason') + expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED') + expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests') + expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL') + expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled') + expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail') + expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted') + expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk') + }) + + it('supports versioned effect-scoped custom event and content extractors', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('custom')) + session.append('test/note', { note: 'event note' }) + session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + let disposeEvent!: () => void + let disposeContent!: () => void + const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { + disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + }, { inject: ['sessionQuery'] })) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const first = provider.live.get(session.id) + expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note']) + expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) + expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) + expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) + + disposeEvent() + disposeContent() + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const second = provider.live.get(session.id) + expect(second?.documents).toEqual([]) + expect(second?.fingerprint).not.toBe(first?.fingerprint) + await extractorFiber.dispose() + }) +}) + +describe('configuration', () => { + it('rejects an impossible default page size and exposes typed errors', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + const error = new SessionQueryError('test', 'SESSION_QUERY_TEST') + expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' }) + }) + + it('uses constructor defaults and removes the service on plugin disposal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionQueryService) + const session = ctx.sessions.create(SessionId('defaults')) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await fiber.dispose() + expect(ctx.sessionQuery).toBeUndefined() + + const direct = new Context() + await direct.plugin(SessionStore) + const service = new SessionQueryService(direct, {}) + const directSession = direct.sessions.create(SessionId('direct-defaults')) + await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await direct.fiber.dispose() + }) +}) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json new file mode 100644 index 0000000000..7153dae8bb --- /dev/null +++ b/packages/session-query/session-query/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4425c6bd41..3ef543eb88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,25 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-query/session-query: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 610ff3f1ea..eada21d0c4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -78,6 +78,7 @@ const GROUP_ORDER = [ 'cordis', 'hooks', 'session-persistence', + 'session-query', 'support', 'ui', ] @@ -97,7 +98,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -106,9 +107,16 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp'], + consumers: ['agent-loop', 'acp', 'session-query'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'sessionQuery', + pkg: 'session-query', + title: 'Session retrieval read model', + mode: 'seam', + note: 'Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers.', + }, { key: 'systemPrompt', pkg: 'system-prompt', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index dc66dc843e..0b661b3930 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -50,6 +50,7 @@ const GROUP_ORDER = [ 'cordis', 'hooks', 'session-persistence', + 'session-query', 'support', 'ui', ] diff --git a/tsconfig.base.json b/tsconfig.base.json index ddba51c53f..227b144f9a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,7 @@ "./packages/cordis/*/src", "./packages/hooks/*/src", "./packages/session-persistence/*/src", + "./packages/session-query/*/src", "./packages/ui/*/src", "./packages/util/*/src", "./packages/support/*/src" diff --git a/tsconfig.build.json b/tsconfig.build.json index edbd2d742b..8cd4001285 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, diff --git a/tsconfig.json b/tsconfig.json index 61d510437c..38a7107477 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, From 18028cad4ff80263b9e48e586e84342a43f1bc08 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 10 Jul 2026 17:29:52 +0800 Subject: [PATCH 10/31] fix(session-query): address review round 1 --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/persistence.md | 14 ++ docs/core-data-structures/session-query.md | 221 ++++++++++++++++++ docs/core-data-structures/session.md | 20 ++ packages/core/session/tests/session.spec.ts | 6 +- .../tests/coordinator-contract.ts | 3 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/src/corpus.ts | 28 ++- .../session-query/src/provider.ts | 5 +- .../session-query/session-query/src/types.ts | 2 +- .../session-query/tests/session-query.spec.ts | 62 ++++- scripts/type-equiv.manifest.json | 29 +++ vendor/README.md | 1 + vendor/cordis/src/events.ts | 10 +- 14 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 docs/core-data-structures/session-query.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9611adab6f..6a31eddd70 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [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` | +| [session-query.md](session-query.md) | the retrieval seam: logical session/event records, filters, traces, search pages, extractors, and provider synchronization types | | [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 | diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4dbb8fb2af..012e606f48 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -73,6 +73,20 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionPersistedChange` — committed-log notification range + +The observe-only `session/persisted` event carries the canonical header and the committed range. A repair can report `toSeq < fromSeq` when it only removes a torn fragment. + +Source: [`packages/session-persistence/session-persistence/src/index.ts`](../../packages/session-persistence/session-persistence/src/index.ts) + +```ts type-equiv +export interface SessionPersistedChange { + kind: 'append' | 'repair' + fromSeq: number + toSeq: number +} +``` + ## The backends Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md new file mode 100644 index 0000000000..8ad0f0cbf5 --- /dev/null +++ b/docs/core-data-structures/session-query.md @@ -0,0 +1,221 @@ +# Session Query + +The provider-neutral retrieval seam over live and optionally persisted sessions. The [package contract](../../packages/session-query/session-query) owns resolution, lifecycle, synchronization, and error behavior; this page catalogs the public data exchanged by callers, extractors, and search providers. + +Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) + +## Logical records and filters + +`SessionRecord` exposes source availability independently from its live-preferred header. `SessionEventRecord` classifies every raw event against the folded surface. + +```ts type-equiv +export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +``` + +```ts type-equiv +export interface SessionRecord { + header: SessionHeader + live: boolean + persisted: boolean +} +``` + +```ts type-equiv +export interface SessionEventRecord { + sessionId: SessionId + seq: number + type: SessionEventType + time: number + surface: SessionEventSurface +} +``` + +Filters are serializable discriminated specs. Each spec is one transform in a chain; the literal types below are shared by in-memory filtering and provider pre-ranking requests. + +```ts type-equiv +export interface SessionQueryRange { + from?: number + to?: number +} +``` + +```ts type-equiv +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | { kind: 'created-at'; range: SessionQueryRange } + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } +``` + +```ts type-equiv +export type SessionEventResultFilter = + | { kind: 'seq'; range: SessionQueryRange } + | { kind: 'time'; range: SessionQueryRange } + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } +``` + +## Search requests and pages + +Both scopes use the same opaque-cursor page envelope. Session hits carry exactly one best event; event hits add only a plain-text snippet to the lightweight record. + +```ts type-equiv +export interface SessionQueryExecContext { + readonly signal?: AbortSignal +} +``` + +```ts type-equiv +export type SessionSearchProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } +``` + +```ts type-equiv +export interface SessionSearchPageRequest { + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionSearchRequest extends SessionSearchPageRequest { + query: string + sessionFilters?: readonly SessionResultFilter[] + eventFilters?: readonly SessionEventResultFilter[] +} +``` + +```ts type-equiv +export interface SessionEventSearchRequest extends SessionSearchPageRequest { + sessionId: SessionId + query: string + filters?: readonly SessionEventResultFilter[] +} +``` + +```ts type-equiv +export interface SessionEventSearchHit extends SessionEventRecord { + snippet: string +} +``` + +```ts type-equiv +export interface SessionSearchHit extends SessionRecord { + bestMatch: SessionEventSearchHit +} +``` + +```ts type-equiv +export interface SessionSearchPage { + providerId: string + items: readonly T[] + nextCursor?: string +} +``` + +## Event reads and traces + +An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. + +```ts type-equiv +export interface SessionEventReadRequest { + sessionId: SessionId + seq: number + before?: number + after?: number +} +``` + +```ts type-equiv +export interface SessionEventWindow { + session: SessionRecord + target: SessionEvent + events: SessionEvent[] + startSeq: number + endSeq: number +} +``` + +```ts type-equiv +export interface SessionLineageNode { + session: SessionRecord + children: SessionLineageNode[] +} +``` + +```ts type-equiv +export interface SessionLineageTrace { + target: SessionRecord + parents: SessionRecord[] + root?: SessionRecord + unresolvedParentId?: SessionId + children: SessionLineageNode[] +} +``` + +```ts type-equiv +export interface SessionEventTrace { + target: SessionEventRecord + shadowedBy?: number + replacementChain: number[] + shadows: number[] + references: number[] + referencedBy: number[] +} +``` + +## Extraction and provider synchronization + +Custom extractors are keyed by declaration-merged event or content discriminants and carry stable cache-invalidation versions. Providers receive complete event documents grouped into independently replaceable persisted and live snapshots. + +```ts type-equiv +export interface SessionEventTextExtractor { + version: string + extract(event: SessionEvent): readonly string[] +} +``` + +```ts type-equiv +export interface SessionContentTextExtractor { + version: string + extract(block: ContentBlockMap[K]): readonly string[] +} +``` + +```ts type-equiv +export interface SessionIndexDocument extends SessionEventRecord { + text: string +} +``` + +```ts type-equiv +export interface SessionIndexSnapshot { + session: SessionRecord + fingerprint: string + documents: readonly SessionIndexDocument[] +} +``` + +```ts type-equiv +export interface SessionPersistedIndexEntry { + sessionId: SessionId + fingerprint: string +} +``` + +```ts type-equiv +export interface SessionSearchProvider { + readonly id: string + status(): SessionSearchProviderStatus + persistedInventory(): Promise + setPersistedActive(active: boolean): Promise + replacePersisted(snapshot: SessionIndexSnapshot): Promise + removePersisted(sessionId: SessionId): Promise + replaceLive(snapshot: SessionIndexSnapshot): Promise + removeLive(sessionId: SessionId): Promise + searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> +} +``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1b12c3fc48..d8292c49b3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,6 +196,26 @@ export interface SurfaceNode { } ``` +### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay + +`foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache. + +```ts type-equiv +export interface SurfaceFoldReplacement { + seq: number + start: number + end: number + shadowedSeqs: number[] +} +``` + +```ts type-equiv +export interface SurfaceFoldResult { + nodes: SurfaceNode[] + replacements: SurfaceFoldReplacement[] +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e51ed923df..b32ae07fdd 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -374,9 +374,12 @@ describe('SessionStore', () => { expect(observations).toHaveLength(1) }) - it('contains rejected session/removed listeners during teardown', async () => { + it('contains failing session/removed listeners without starving later observers', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + const observed: SessionId[] = [] + ctx.on('session/removed', () => { throw new Error('synchronous observer failed') }) + ctx.on('session/removed', header => void observed.push(header.id)) ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) const session = ctx.sessions.prepare(SessionId('contained')) const detach = ctx.sessions.enter(session) @@ -385,6 +388,7 @@ describe('SessionStore', () => { await Promise.resolve() await Promise.resolve() expect(ctx.sessions.get(session.id)).toBeUndefined() + expect(observed).toEqual([session.id]) }) it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 6d95fbb4b2..4cf4e6ff67 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -128,11 +128,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] + ctx.on('session/persisted', () => { throw new Error('synchronous derived read model failed') }) ctx.on('session/persisted', (header, change) => { observed.push({ headerId: header.id, change: structuredClone(change) }) header.createdAt = -1 - return Promise.reject(new Error('derived read model failed')) }) + ctx.on('session/persisted', () => Promise.reject(new Error('asynchronous derived read model failed'))) try { const m = meta('notifications', WORK) await ctx.sessionPersistence.create(m) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index ab06959ea5..70b57dad07 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -22,7 +22,7 @@ Session filters cover id, exact cwd, inclusive creation time, parent id/root, an ## Full-text providers -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 980e11e416..87fb7a5533 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -12,10 +12,18 @@ interface PersistenceBinding { token: symbol service: SessionPersistence headers: Map + /** Notifications retained until a list that began after them completes. */ + observations: Map + observationGeneration: number error?: unknown refreshing: Promise | undefined } +interface PersistedObservation { + generation: number + header: SessionHeader +} + /** Active persistence view used by provider reconciliation. */ export interface PersistenceView { /** Canonical headers in deterministic creation order. */ @@ -132,6 +140,8 @@ export class SessionCorpus { token: Symbol('session-query-persistence'), service, headers: new Map(), + observations: new Map(), + observationGeneration: 0, refreshing: undefined, } this._persistence = binding @@ -140,7 +150,10 @@ export class SessionCorpus { ctx.on('session/persisted', (header) => { /* v8 ignore next -- a stale notification can race optional-service disposal */ if (this._persistence?.token !== binding.token) return - binding.headers.set(header.id, structuredClone(header)) + const snapshot = structuredClone(header) + const observation = { generation: ++binding.observationGeneration, header: snapshot } + binding.headers.set(header.id, snapshot) + binding.observations.set(header.id, observation) this._onPersistenceChange(true) }) ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') @@ -155,10 +168,21 @@ export class SessionCorpus { private _refreshPersistence(binding: PersistenceBinding): Promise { if (binding.refreshing !== undefined) return binding.refreshing + const startGeneration = binding.observationGeneration const refresh = binding.service.list().then((headers) => { /* v8 ignore next -- a list completion can race optional-service disposal */ if (this._persistence?.token !== binding.token) return - binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)])) + const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)])) + for (const [id, observation] of binding.observations) { + // A notification newer than this list's snapshot is the authoritative + // read-your-writes layer; older ones must already be present in list(). + if (observation.generation > startGeneration) { + nextHeaders.set(id, structuredClone(observation.header)) + } else { + binding.observations.delete(id) + } + } + binding.headers = nextHeaders binding.error = undefined this._onPersistenceChange(true) }).catch((error: unknown) => { diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 7000c0e6d1..1574bcc0b2 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -292,7 +292,10 @@ export class SessionProviderCoordinator { if (page.providerId !== state.provider.id) { throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') } - return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) } + if (page.items.length > limit) { + throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR') + } + return page } } diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index d3e819cf1a..43c63a034c 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -118,7 +118,7 @@ export interface SessionSearchHit extends SessionRecord { export interface SessionSearchPage { /** Stable id of the provider that produced this page. */ providerId: string - /** Ranked hits in deterministic provider order. */ + /** Ranked hits in deterministic provider order, no longer than the requested limit. */ items: readonly T[] /** Opaque next-page cursor, absent when the result is exhausted. */ nextCursor?: string diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 88fced88d3..0da0b4f41d 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -52,11 +52,15 @@ class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown static loadFailure: unknown + static listBarrier: Promise | undefined + static onList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined this.loadFailure = undefined + this.listBarrier = undefined + this.onList = undefined } create(meta: SessionHeader): Promise { @@ -80,7 +84,9 @@ class TestPersistence extends SessionPersistence { list(): Promise { if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) - return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))) + const snapshot = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TestPersistence.onList?.() + return (TestPersistence.listBarrier ?? Promise.resolve()).then(() => snapshot) } } @@ -182,6 +188,12 @@ function asError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)) } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + describe('pure result filters', () => { it('chains session filters as AND while values within one filter are OR', () => { const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } @@ -379,8 +391,8 @@ describe('provider selection and synchronization', () => { { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, ], nextCursor: 'next' } - const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] }) - expect(page.items).toHaveLength(2) + await expect(ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })) + .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) @@ -534,6 +546,30 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.has(persisted.id)).toBe(true) }) + it('preserves persisted observations that race an older inventory listing', async () => { + TestPersistence.reset() + const listStarted = deferred() + const releaseList = deferred() + TestPersistence.onList = listStarted.resolve + TestPersistence.listBarrier = releaseList.promise + const ctx = await liveContext() + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + await ctx.plugin(TestPersistence) + await listStarted.promise + + const announced = header('racing-announcement', 3) + TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('after durable notification') }) + await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) + const search = ctx.sessionQuery.searchSessions({ query: 'notification' }) + releaseList.resolve() + + await expect(search).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('after durable notification') + TestPersistence.listBarrier = undefined + TestPersistence.onList = undefined + }) + it('synchronizes only a live target for event search and retries dirty failures', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('target')) @@ -646,11 +682,9 @@ describe('semantic text extractors', () => { session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const provider = new FakeProvider() ctx.sessionQuery.registerSearchProvider(provider) - let disposeEvent!: () => void - let disposeContent!: () => void const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) }, { inject: ['sessionQuery'] })) await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) @@ -663,13 +697,21 @@ describe('semantic text extractors', () => { expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - disposeEvent() - disposeContent() + await extractorFiber.dispose() await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = provider.live.get(session.id) expect(second?.documents).toEqual([]) expect(second?.fingerprint).not.toBe(first?.fingerprint) - await extractorFiber.dispose() + + const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) + inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: block => [`replacement ${block.value}`] }) + }, { inject: ['sessionQuery'] })) + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const third = provider.live.get(session.id) + expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) + expect(third?.fingerprint).not.toBe(second?.fingerprint) + await replacementFiber.dispose() }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 881ce06578..671419ab59 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,9 +31,38 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistedChange", "source": "packages/session-persistence/session-persistence/src/index.ts" }, + + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryRange", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryExecContext", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProviderStatus", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionContentTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionPersistedIndexEntry", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProvider", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, diff --git a/vendor/README.md b/vendor/README.md index bf0f0b5a8c..dabf1b17af 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +6. **`cordis/src/events.ts`**: `parallel()` captures each listener invocation in its own promise before awaiting the group. A synchronous throw therefore rejects the dispatch without preventing later parallel listeners from starting. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 4461816537..6e5212b734 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -106,7 +106,15 @@ export class EventsService { /** Run listeners concurrently and wait for all of them. */ async parallel(...args: any[]) { - await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) + const callbacks = this.dispatch('emit', args) + const results = callbacks.map((cb) => { + try { + return Promise.resolve(cb(...args)) + } catch (error: unknown) { + return Promise.reject(error) + } + }) + await Promise.all(results) } /** Run listeners synchronously without waiting for returned promises. */ From 352ea6cf4f96d656f896f8a18519503d4bfbb917 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:19:34 +0800 Subject: [PATCH 11/31] fix(session-query): checkpoint review round 3 --- docs/core-data-structures/session-query.md | 47 ++++++++++++++++++- docs/event-producer-consumer.md | 6 +-- .../cordis/tool-cordis/src/api-catalog.ts | 10 +++- packages/core/session/tests/session.spec.ts | 6 +-- .../tests/coordinator-contract.ts | 3 +- .../session-query/session-query/README.md | 6 +++ .../session-query/session-query/src/config.ts | 36 +++++++++++++- .../session-query/session-query/src/index.ts | 2 +- .../session-query/src/provider.ts | 9 ++-- .../session-query/session-query/src/types.ts | 16 ++++++- .../session-query/tests/session-query.spec.ts | 35 ++++++++------ scripts/gen-doc-graphs.ts | 11 +++-- scripts/type-equiv.manifest.json | 3 ++ vendor/README.md | 2 +- vendor/cordis/src/events.ts | 12 ++--- 15 files changed, 153 insertions(+), 51 deletions(-) diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 8ad0f0cbf5..b824e99f1e 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -95,6 +95,20 @@ export interface SessionEventSearchRequest extends SessionSearchPageRequest { } ``` +The service resolves caller requests before crossing the provider seam, so provider implementations always receive a validated page limit. + +```ts type-equiv +export interface SessionSearchSpec extends SessionSearchRequest { + limit: number +} +``` + +```ts type-equiv +export interface SessionEventSearchSpec extends SessionEventSearchRequest { + limit: number +} +``` + ```ts type-equiv export interface SessionEventSearchHit extends SessionEventRecord { snippet: string @@ -115,6 +129,35 @@ export interface SessionSearchPage { } ``` +## Errors + +The service exposes a closed machine-routable error taxonomy; messages and causes provide detail but do not add codes. + +```ts type-equiv +export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' + | 'SESSION_QUERY_DUPLICATE_PROVIDER' + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_EXTRACTOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_QUERY' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'SESSION_QUERY_PROVIDER_ERROR' + | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' +``` + ## Event reads and traces An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. @@ -215,7 +258,7 @@ export interface SessionSearchProvider { removePersisted(sessionId: SessionId): Promise replaceLive(snapshot: SessionIndexSnapshot): Promise removeLive(sessionId: SessionId): Promise - searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> - searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> + searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23e3844375..0b8c4fce02 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../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:55`](../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/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../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), [`session-query`](../packages/session-query/session-query), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-query`](../packages/session-query/session-query) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7b3cd1297f..12872d689f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -737,6 +737,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', }, + { + name: 'SessionEventSearchSpec', + declaration: 'export interface SessionEventSearchSpec extends SessionEventSearchRequest {\n limit: number;\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -819,7 +823,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSearchProvider', - declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise>;\n}', + declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise>;\n}', }, { name: 'SessionSearchProviderStatus', @@ -829,6 +833,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', }, + { + name: 'SessionSearchSpec', + declaration: 'export interface SessionSearchSpec extends SessionSearchRequest {\n limit: number;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index b32ae07fdd..e51ed923df 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -374,12 +374,9 @@ describe('SessionStore', () => { expect(observations).toHaveLength(1) }) - it('contains failing session/removed listeners without starving later observers', async () => { + it('contains rejected session/removed listeners during teardown', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const observed: SessionId[] = [] - ctx.on('session/removed', () => { throw new Error('synchronous observer failed') }) - ctx.on('session/removed', header => void observed.push(header.id)) ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) const session = ctx.sessions.prepare(SessionId('contained')) const detach = ctx.sessions.enter(session) @@ -388,7 +385,6 @@ describe('SessionStore', () => { await Promise.resolve() await Promise.resolve() expect(ctx.sessions.get(session.id)).toBeUndefined() - expect(observed).toEqual([session.id]) }) it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 4cf4e6ff67..6d95fbb4b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -128,12 +128,11 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] - ctx.on('session/persisted', () => { throw new Error('synchronous derived read model failed') }) ctx.on('session/persisted', (header, change) => { observed.push({ headerId: header.id, change: structuredClone(change) }) header.createdAt = -1 + return Promise.reject(new Error('derived read model failed')) }) - ctx.on('session/persisted', () => Promise.reject(new Error('asynchronous derived read model failed'))) try { const m = meta('notifications', WORK) await ctx.sessionPersistence.create(m) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 70b57dad07..5dc5aecc34 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -28,6 +28,12 @@ The service feeds providers two independent layers: a durable persisted base (`p Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. +Providers receive resolved `SessionSearchSpec` and `SessionEventSearchSpec` values whose `limit` is required after service defaulting and validation. Public service callers use `SessionSearchRequest` and `SessionEventSearchRequest`, where `limit` remains optional. + +## Errors + +`SessionQueryError.code` is the closed `SessionQueryErrorCode` union: `SESSION_QUERY_ABORTED`, `SESSION_QUERY_DUPLICATE_EXTRACTOR`, `SESSION_QUERY_DUPLICATE_PROVIDER`, `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INDEX_FAILED`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_EXTRACTOR`, `SESSION_QUERY_INVALID_FILTER`, `SESSION_QUERY_INVALID_LIMIT`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_QUERY`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_PROVIDER_AMBIGUOUS`, `SESSION_QUERY_PROVIDER_CONFIGURED_MISSING`, `SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE`, `SESSION_QUERY_PROVIDER_ERROR`, `SESSION_QUERY_PROVIDER_UNAVAILABLE`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. + ## Text extractors Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 6f8443aeb1..c70eb52988 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -25,5 +25,37 @@ export interface Config { readWindowMax?: number } -/** Typed session-query failure with a stable machine-routable code. */ -export class SessionQueryError extends HarnessError {} +/** Complete stable machine-routable failure taxonomy for session-query. */ +export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' + | 'SESSION_QUERY_DUPLICATE_PROVIDER' + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_EXTRACTOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_QUERY' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'SESSION_QUERY_PROVIDER_ERROR' + | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' + +/** Typed session-query failure whose `code` is one closed taxonomy member. */ +export class SessionQueryError extends HarnessError { + declare readonly code: SessionQueryErrorCode + + // The base stores the value; this signature narrows its open string code. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) { + super(message, code, options) + } +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 46e3c15c32..1145332c23 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -40,7 +40,7 @@ import { SessionProviderCoordinator } from './provider.ts' import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' export type * from './types.ts' -export type { Config } from './config.ts' +export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_DEFAULT_LIMIT, SESSION_QUERY_MAX_LIMIT, diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 1574bcc0b2..5fd99fde0a 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -8,12 +8,14 @@ import type { SessionEventRecord, SessionEventSearchHit, SessionEventSearchRequest, + SessionEventSearchSpec, SessionQueryExecContext, SessionRecord, SessionSearchHit, SessionSearchPage, SessionSearchProvider, SessionSearchRequest, + SessionSearchSpec, } from './types.ts' import type { Config } from './config.ts' import { SessionQueryError } from './config.ts' @@ -28,9 +30,6 @@ interface ProviderState { liveSync: Map> } -type NormalizedSessionSearchRequest = SessionSearchRequest & { limit: number } -type NormalizedEventSearchRequest = SessionEventSearchRequest & { limit: number } - /** Coordinates one selected provider against live and persisted corpus layers. */ export class SessionProviderCoordinator { private readonly _configuredProviderId: string | undefined @@ -257,7 +256,7 @@ export class SessionProviderCoordinator { return single } - private _normalizeSessionSearch(request: SessionSearchRequest): NormalizedSessionSearchRequest { + private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec { const query = this._queryText(request.query) const limit = this._limitValue(request.limit) filterSessionResults([], request.sessionFilters ?? []) @@ -265,7 +264,7 @@ export class SessionProviderCoordinator { return { ...request, query, limit } } - private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest { + private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec { const query = this._queryText(request.query) const limit = this._limitValue(request.limit) filterEventResults([], request.filters ?? []) diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 43c63a034c..11e6f6d0fc 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -102,6 +102,18 @@ export interface SessionEventSearchRequest extends SessionSearchPageRequest { filters?: readonly SessionEventResultFilter[] } +/** Provider-facing cross-session search spec after service normalization. */ +export interface SessionSearchSpec extends SessionSearchRequest { + /** Required page size validated and defaulted by the query service. */ + limit: number +} + +/** Provider-facing event search spec after service normalization. */ +export interface SessionEventSearchSpec extends SessionEventSearchRequest { + /** Required page size validated and defaulted by the query service. */ + limit: number +} + /** One lightweight event search hit with provider-produced evidence text. */ export interface SessionEventSearchHit extends SessionEventRecord { /** Plain-text excerpt explaining the match. */ @@ -281,12 +293,12 @@ export interface SessionSearchProvider { * @param exec - optional cancellation context. * @returns one ranked session page. */ - searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> /** * Search events within one logical session. * @param request - target session, query, filters, and pagination. * @param exec - optional cancellation context. * @returns one ranked event page. */ - searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 0da0b4f41d..88d7e906f6 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -12,14 +12,15 @@ import SessionQueryService, { } from '@deepseek-ai/dsh-session-query' import type { SessionEventSearchHit, - SessionEventSearchRequest, + SessionEventSearchSpec, SessionIndexSnapshot, + SessionQueryErrorCode, SessionRecord, SessionSearchHit, SessionSearchPage, SessionSearchProvider, SessionSearchProviderStatus, - SessionSearchRequest, + SessionSearchSpec, } from '@deepseek-ai/dsh-session-query' declare module '@deepseek-ai/dsh-llm' { @@ -98,8 +99,8 @@ class FakeProvider implements SessionSearchProvider { activeHistory: boolean[] = [] removedPersisted: SessionIdType[] = [] removedLive: SessionIdType[] = [] - sessionRequests: SessionSearchRequest[] = [] - eventRequests: SessionEventSearchRequest[] = [] + sessionRequests: SessionSearchSpec[] = [] + eventRequests: SessionEventSearchSpec[] = [] failNextLive = false failNextPersisted = false failNextActive = false @@ -162,12 +163,12 @@ class FakeProvider implements SessionSearchProvider { return Promise.resolve() } - searchSessions(request: SessionSearchRequest): Promise> { + searchSessions(request: SessionSearchSpec): Promise> { this.sessionRequests.push(structuredClone(request)) return Promise.resolve(structuredClone(this.sessionPage)) } - searchEvents(request: SessionEventSearchRequest): Promise> { + searchEvents(request: SessionEventSearchSpec): Promise> { this.eventRequests.push(structuredClone(request)) return Promise.resolve(structuredClone(this.eventPage)) } @@ -180,7 +181,7 @@ async function liveContext(config: ConstructorParameters { TestPersistence.listFailure = undefined TestPersistence.loadFailure = new Error('load unavailable') await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE')) + TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_EVENT_NOT_FOUND') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) await persistenceFiber.dispose() TestPersistence.loadFailure = undefined @@ -682,9 +683,11 @@ describe('semantic text extractors', () => { session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const provider = new FakeProvider() ctx.sessionQuery.registerSearchProvider(provider) + let disposeEvent!: () => void + let disposeContent!: () => void const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) }, { inject: ['sessionQuery'] })) await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) @@ -697,11 +700,13 @@ describe('semantic text extractors', () => { expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - await extractorFiber.dispose() + disposeEvent() + disposeContent() await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = provider.live.get(session.id) expect(second?.documents).toEqual([]) expect(second?.fingerprint).not.toBe(first?.fingerprint) + await extractorFiber.dispose() const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) @@ -712,6 +717,8 @@ describe('semantic text extractors', () => { expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) expect(third?.fingerprint).not.toBe(second?.fingerprint) await replacementFiber.dispose() + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + expect(provider.live.get(session.id)?.documents).toEqual([]) }) }) @@ -721,8 +728,8 @@ describe('configuration', () => { await ctx.plugin(SessionStore) await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) - const error = new SessionQueryError('test', 'SESSION_QUERY_TEST') - expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' }) + const error = new SessionQueryError('test', 'SESSION_QUERY_INVALID_CONFIG') + expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_INVALID_CONFIG' }) }) it('uses constructor defaults and removes the service on plugin disposal', async () => { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index eada21d0c4..0318e33fea 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -531,7 +531,7 @@ function collectEventRelations(): Map { 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)) { + if (!isCordisContextReceiver(node.expression)) { ts.forEachChild(node, visit) return } @@ -561,9 +561,12 @@ function collectEventRelations(): Map { return out } -function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { - const target = expr.expression.getText(sf) - return target === 'ctx' || target === 'this.ctx' +function isCordisContextReceiver(expr: ts.PropertyAccessExpression): boolean { + const receiver = expr.expression + if (ts.isIdentifier(receiver)) return receiver.text === 'ctx' || receiver.text === '_ctx' + return ts.isPropertyAccessExpression(receiver) + && receiver.expression.kind === ts.SyntaxKind.ThisKeyword + && (receiver.name.text === 'ctx' || receiver.name.text === '_ctx') } function eventArg(args: ts.NodeArray, method: string): string | undefined { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 671419ab59..32d8e91f6e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -49,9 +49,12 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, diff --git a/vendor/README.md b/vendor/README.md index dabf1b17af..856b958e8f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,7 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/events.ts`**: `parallel()` captures each listener invocation in its own promise before awaiting the group. A synchronous throw therefore rejects the dispatch without preventing later parallel listeners from starting. +6. **`cordis/src/events.ts`**: a `FIXME` documents the upstream `parallel()` bug where a synchronous listener throw aborts callback enumeration and starves later listeners; runtime behavior remains upstream-identical pending an upstream fix. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 6e5212b734..e483afadf5 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -106,15 +106,9 @@ export class EventsService { /** Run listeners concurrently and wait for all of them. */ async parallel(...args: any[]) { - const callbacks = this.dispatch('emit', args) - const results = callbacks.map((cb) => { - try { - return Promise.resolve(cb(...args)) - } catch (error: unknown) { - return Promise.reject(error) - } - }) - await Promise.all(results) + // FIXME(cordis upstream): A synchronous listener throw aborts callback + // enumeration here and starves later parallel listeners. Fix upstream. + await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) } /** Run listeners synchronously without waiting for returned promises. */ From fa728a00bd1f63f9062ea8e158745c960e62b2ba Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:42:40 +0800 Subject: [PATCH 12/31] fix(session-query): reconcile concurrent live removals --- .../session-query/src/provider.ts | 24 ++++++++--- .../session-query/tests/session-query.spec.ts | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 5fd99fde0a..d6190701a7 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -26,10 +26,15 @@ interface ProviderState { active: boolean chain: Promise liveIds: Set - fullSync: Promise | undefined + fullSync: FullSync | undefined liveSync: Map> } +interface FullSync { + liveKey: string + promise: Promise +} + /** Coordinates one selected provider against live and persisted corpus layers. */ export class SessionProviderCoordinator { private readonly _configuredProviderId: string | undefined @@ -159,7 +164,15 @@ export class SessionProviderCoordinator { } private _syncAll(state: ProviderState): Promise { - if (state.fullSync !== undefined) return state.fullSync + // Capture the direct source before awaiting: only searches that observed + // the same live corpus may share an in-flight full synchronization. + const liveSessions = this._corpus().listLive() + const liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ + snapshot.session.header.id, + snapshot.fingerprint, + snapshot.session.persisted, + ])) + if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ if (!state.active) return @@ -169,12 +182,13 @@ export class SessionProviderCoordinator { } else { await this._syncPersisted(state, persistence) } - await this._replaceLiveCorpus(state, this._corpus().listLive()) + await this._replaceLiveCorpus(state, liveSessions) }) - state.fullSync = promise + const fullSync = { liveKey, promise } + state.fullSync = fullSync void promise.finally(() => { /* v8 ignore next -- a newer invalidation may already own the sync slot */ - if (state.fullSync === promise) state.fullSync = undefined + if (state.fullSync === fullSync) state.fullSync = undefined }).catch(() => undefined) return promise } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 88d7e906f6..5daa085bb3 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -467,6 +467,48 @@ describe('provider selection and synchronization', () => { .rejects.toThrow('search failed') }) + it('reconciles a live removal observed while an older full sync is in flight', async () => { + const ctx = await liveContext() + const session = ctx.sessions.prepare(SessionId('removed-during-sync')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + session.append('user/message', { content: [{ type: 'text', text: 'stale live hit' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const replaceStarted = deferred() + const releaseReplace = deferred() + provider.replaceLive = async (snapshot) => { + replaceStarted.resolve() + await releaseReplace.promise + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const searchLiveIds: SessionIdType[][] = [] + provider.searchSessions = () => { + searchLiveIds.push([...provider.live.keys()]) + const items: SessionSearchHit[] = [] + for (const snapshot of provider.live.values()) { + const document = snapshot.documents[0] + if (document === undefined) continue + items.push({ + ...structuredClone(snapshot.session), + bestMatch: { ...structuredClone(document), snippet: document.text }, + }) + } + return Promise.resolve({ providerId: provider.id, items }) + } + ctx.sessionQuery.registerSearchProvider(provider) + + const first = ctx.sessionQuery.searchSessions({ query: 'stale' }) + await replaceStarted.promise + detach() + const second = ctx.sessionQuery.searchSessions({ query: 'stale' }) + releaseReplace.resolve() + + await first + await expect(second).resolves.toMatchObject({ items: [] }) + expect(provider.removedLive).toContain(session.id) + expect(searchLiveIds.at(-1)).toEqual([]) + }) + it('searches a persisted target after corpus reconciliation', async () => { const persisted = header('event-persisted', 1) TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) @@ -528,6 +570,7 @@ describe('provider selection and synchronization', () => { await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') + expect(provider.live.get(overlaid.id)?.session).toMatchObject({ live: true, persisted: true }) expect(provider.removedPersisted).toEqual([SessionId('stale')]) expect(provider.activeHistory.at(-1)).toBe(true) const fingerprint = provider.persisted.get(persisted.id)?.fingerprint From 6c6ce08a39a752950d903035f700cdb9563a2276 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:52:17 +0800 Subject: [PATCH 13/31] fix(session-query): preserve sync error typing --- .../session-query/src/provider.ts | 28 +++++++++++++------ .../session-query/tests/session-query.spec.ts | 24 ++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index d6190701a7..a8b6e3038c 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -166,12 +166,18 @@ export class SessionProviderCoordinator { private _syncAll(state: ProviderState): Promise { // Capture the direct source before awaiting: only searches that observed // the same live corpus may share an in-flight full synchronization. - const liveSessions = this._corpus().listLive() - const liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ - snapshot.session.header.id, - snapshot.fingerprint, - snapshot.session.persisted, - ])) + let liveSessions: Session[] + let liveKey: string + try { + liveSessions = this._corpus().listLive() + liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ + snapshot.session.header.id, + snapshot.fingerprint, + snapshot.session.persisted, + ])) + } catch (error: unknown) { + return Promise.reject(this._synchronizationError(state, error)) + } if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ @@ -242,12 +248,16 @@ export class SessionProviderCoordinator { const next = state.chain.then(operation, operation) state.chain = next.then(() => undefined, () => undefined) return next.catch((error: unknown) => { - /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ - if (error instanceof SessionQueryError) throw error - throw new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + throw this._synchronizationError(state, error) }) } + private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { + /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ + if (error instanceof SessionQueryError) return error + return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + } + private _resolveProvider(): ProviderState { if (this._configuredProviderId !== undefined) { const state = this._providers.get(this._configuredProviderId) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 5daa085bb3..6d7e9cab33 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -673,6 +673,30 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') }) + + it('types synchronous extractor failures during full-search key construction', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('throwing-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const cause = new Error('custom extractor failed') + ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'throwing-v1', + extract: () => { throw cause }, + }) + + let thrown: unknown + try { + await ctx.sessionQuery.searchSessions({ query: 'x' }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(SessionQueryError) + expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) + expect(asError(thrown).message).toContain(`provider "${provider.id}"`) + expect(provider.sessionRequests).toEqual([]) + }) }) describe('semantic text extractors', () => { From f4b0dba3809f43944ebae8abbb0ebb7b1e879433 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:59:54 +0800 Subject: [PATCH 14/31] fix(session-query): contain sync cancellation failures --- .../session-query/src/provider.ts | 14 ++++- .../session-query/tests/session-query.spec.ts | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index a8b6e3038c..7152e9f5e5 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -225,7 +225,12 @@ export class SessionProviderCoordinator { private _syncLive(state: ProviderState, session: Session): Promise { const existing = state.liveSync.get(session.id) if (existing !== undefined) return existing - const snapshot = this._snapshotLive(session) + let snapshot: ReturnType + try { + snapshot = this._snapshotLive(session) + } catch (error: unknown) { + return Promise.reject(this._synchronizationError(state, error)) + } const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ if (!state.active) return @@ -324,7 +329,12 @@ export class SessionProviderCoordinator { function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return work - if (signal.aborted) return Promise.reject(aborted()) + if (signal.aborted) { + // Cancellation supersedes the caller's result, but shared work must still + // have a rejection observer when it has already failed synchronously. + void work.catch((_supersededError: unknown) => undefined) + return Promise.reject(aborted()) + } return new Promise((resolve, reject) => { const onAbort = () => { reject(aborted()) } signal.addEventListener('abort', onAbort, { once: true }) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 6d7e9cab33..5249cd22ae 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -697,6 +697,60 @@ describe('provider selection and synchronization', () => { expect(asError(thrown).message).toContain(`provider "${provider.id}"`) expect(provider.sessionRequests).toEqual([]) }) + + it('observes synchronous synchronization failure when the caller is already aborted', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('aborted-throwing-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'aborted-throwing-v1', + extract: () => { throw new Error('superseded extraction failure') }, + }) + const controller = new AbortController() + controller.abort() + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('types synchronous live-target extraction failures and leaves retries clean', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('throwing-live-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const cause = new Error('live extractor failed') + const disposeExtractor = ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'live-throwing-v1', + extract: () => { throw cause }, + }) + + let thrown: unknown + try { + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(SessionQueryError) + expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) + expect(asError(thrown).message).toContain(`provider "${provider.id}"`) + expect(provider.eventRequests).toEqual([]) + + disposeExtractor() + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })) + .resolves.toMatchObject({ providerId: provider.id }) + expect(provider.eventRequests).toHaveLength(1) + }) }) describe('semantic text extractors', () => { From 8dd85bd1852ca7fc2a3f481c561bdc7184d001a0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 11:48:32 +0800 Subject: [PATCH 15/31] refactor(session-query): simplify provider synchronization --- docs/event-producer-consumer.md | 6 +- .../session-query/session-query/src/corpus.ts | 9 +- .../session-query/src/extraction.ts | 6 +- .../session-query/session-query/src/index.ts | 6 +- .../session-query/src/provider.ts | 154 ++++++------------ .../session-query/tests/session-query.spec.ts | 63 +++++-- 6 files changed, 106 insertions(+), 138 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b8c4fce02..23e3844375 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../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), [`session-query`](../packages/session-query/session-query), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../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:55`](../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:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-query`](../packages/session-query/session-query) | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 87fb7a5533..84d6967e01 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -36,10 +36,7 @@ export interface PersistenceView { export class SessionCorpus { private _persistence: PersistenceBinding | undefined - constructor( - private readonly _ctx: Context, - private readonly _onPersistenceChange: (active: boolean) => void, - ) { + constructor(private readonly _ctx: Context) { _ctx.effect(() => { const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { this._attachPersistence(childCtx, childCtx.sessionPersistence) @@ -145,7 +142,6 @@ export class SessionCorpus { refreshing: undefined, } this._persistence = binding - this._onPersistenceChange(true) void this._refreshPersistence(binding) ctx.on('session/persisted', (header) => { /* v8 ignore next -- a stale notification can race optional-service disposal */ @@ -154,7 +150,6 @@ export class SessionCorpus { const observation = { generation: ++binding.observationGeneration, header: snapshot } binding.headers.set(header.id, snapshot) binding.observations.set(header.id, observation) - this._onPersistenceChange(true) }) ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') } @@ -163,7 +158,6 @@ export class SessionCorpus { /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ if (this._persistence?.token !== binding.token) return this._persistence = undefined - this._onPersistenceChange(false) } private _refreshPersistence(binding: PersistenceBinding): Promise { @@ -184,7 +178,6 @@ export class SessionCorpus { } binding.headers = nextHeaders binding.error = undefined - this._onPersistenceChange(true) }).catch((error: unknown) => { /* v8 ignore next -- a failed list can race optional-service disposal */ if (this._persistence?.token !== binding.token) return diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 58db38c8ef..c4ad294a4f 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -37,7 +37,7 @@ export class SessionTextExtractors { private readonly _eventExtractors = new Map() private readonly _contentExtractors = new Map() - constructor(private readonly _onChange: () => void) { + constructor() { this._installCoreExtractors() } @@ -63,10 +63,8 @@ export class SessionTextExtractors { } const dispose = ctx.effect(function* (this: SessionTextExtractors) { this._eventExtractors.set(type, stored) - this._onChange() yield () => { this._eventExtractors.delete(type) - this._onChange() } }.bind(this), `sessionQuery.eventExtractor(${type})`) return () => void dispose() @@ -94,10 +92,8 @@ export class SessionTextExtractors { } const dispose = ctx.effect(function* (this: SessionTextExtractors) { this._contentExtractors.set(type, stored) - this._onChange() yield () => { this._contentExtractors.delete(type) - this._onChange() } }.bind(this), `sessionQuery.contentExtractor(${type})`) return () => void dispose() diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 1145332c23..edab597dd5 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -78,13 +78,13 @@ export class SessionQueryService extends Service { if (defaultLimit > maxLimit) { throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') } - this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() }) - this._providers = new SessionProviderCoordinator(ctx, { + this._extractors = new SessionTextExtractors() + this._providers = new SessionProviderCoordinator({ ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, defaultLimit, maxLimit, }, () => this._corpus, this._extractors) - this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) }) + this._corpus = new SessionCorpus(ctx) } /** diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 7152e9f5e5..c3ec4ad0f4 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -26,13 +26,6 @@ interface ProviderState { active: boolean chain: Promise liveIds: Set - fullSync: FullSync | undefined - liveSync: Map> -} - -interface FullSync { - liveKey: string - promise: Promise } /** Coordinates one selected provider against live and persisted corpus layers. */ @@ -43,7 +36,6 @@ export class SessionProviderCoordinator { private readonly _providers = new Map() constructor( - private readonly _ctx: Context, config: Required> & Pick, private readonly _corpus: () => SessionCorpus, private readonly _extractors: SessionTextExtractors, @@ -51,9 +43,6 @@ export class SessionProviderCoordinator { this._configuredProviderId = config.searchProvider this._defaultLimit = config.defaultLimit this._maxLimit = config.maxLimit - _ctx.on('session/created', (session) => { this.invalidateLive(session.id) }) - _ctx.on('session/event', (session) => { this.invalidateLive(session.id) }) - _ctx.on('session/removed', (header) => { this.invalidateLive(header.id) }) } /** @@ -71,14 +60,9 @@ export class SessionProviderCoordinator { active: true, chain: Promise.resolve(), liveIds: new Set(), - fullSync: undefined, - liveSync: new Map(), } const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { this._providers.set(provider.id, state) - void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => { - this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`) - }) yield () => { state.active = false this._providers.delete(provider.id) @@ -99,9 +83,12 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeSessionSearch(request) - await waitFor(this._syncAll(state), exec?.signal) - const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal) - return this._validateSearchPage(state, result, normalized.limit) + const work = this._runFullSearch(state, async () => { + if (exec?.signal?.aborted) throw aborted() + const result = await state.provider.searchSessions(normalized, exec) + return this._validateSearchPage(state, result, normalized.limit) + }) + return waitFor(work, exec?.signal) } /** @@ -116,87 +103,41 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeEventSearch(request) + const query = async (): Promise> => { + if (exec?.signal?.aborted) throw aborted() + const result = await state.provider.searchEvents(normalized, exec) + return this._validateSearchPage(state, result, normalized.limit) + } const live = this._corpus().getLive(request.sessionId) + let work: Promise> if (live !== undefined) { - await waitFor(this._syncLive(state, live), exec?.signal) + work = this._runLiveSearch(state, live, query) } else { const persistence = await this._corpus().persistenceView() if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') } - await waitFor(this._syncAll(state), exec?.signal) + work = this._runFullSearch(state, query) } - const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal) - return this._validateSearchPage(state, result, normalized.limit) + return waitFor(work, exec?.signal) } - /** - * Invalidate provider synchronization after one live source change. - * @param sessionId - changed live session. - */ - invalidateLive(sessionId: SessionId): void { - for (const state of this._providers.values()) { - state.fullSync = undefined - state.liveSync.delete(sessionId) - } - } - - /** Invalidate all source/extractor-derived provider snapshots. */ - invalidateAll(): void { - for (const state of this._providers.values()) { - state.fullSync = undefined - state.liveSync.clear() - } - } - - /** - * React to persistence mount, inventory change, or unmount. - * @param active - whether canonical persistence remains mounted. - */ - persistenceChanged(active: boolean): void { - for (const state of this._providers.values()) state.fullSync = undefined - if (active) return - for (const state of this._providers.values()) { - void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => { - this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`) + private _runFullSearch(state: ProviderState, query: () => Promise): Promise { + const liveSessions = this._corpus().listLive() + return this._serialize(state, async () => { + await this._synchronize(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + const persistence = await this._corpus().persistenceView() + if (persistence === undefined) { + await state.provider.setPersistedActive(false) + } else { + await this._syncPersisted(state, persistence) + } + await this._replaceLiveCorpus(state, liveSessions) }) - } - } - - private _syncAll(state: ProviderState): Promise { - // Capture the direct source before awaiting: only searches that observed - // the same live corpus may share an in-flight full synchronization. - let liveSessions: Session[] - let liveKey: string - try { - liveSessions = this._corpus().listLive() - liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ - snapshot.session.header.id, - snapshot.fingerprint, - snapshot.session.persisted, - ])) - } catch (error: unknown) { - return Promise.reject(this._synchronizationError(state, error)) - } - if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise - const promise = this._enqueue(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return - const persistence = await this._corpus().persistenceView() - if (persistence === undefined) { - await state.provider.setPersistedActive(false) - } else { - await this._syncPersisted(state, persistence) - } - await this._replaceLiveCorpus(state, liveSessions) + return query() }) - const fullSync = { liveKey, promise } - state.fullSync = fullSync - void promise.finally(() => { - /* v8 ignore next -- a newer invalidation may already own the sync slot */ - if (state.fullSync === fullSync) state.fullSync = undefined - }).catch(() => undefined) - return promise } private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { @@ -222,39 +163,42 @@ export class SessionProviderCoordinator { state.liveIds = liveIds } - private _syncLive(state: ProviderState, session: Session): Promise { - const existing = state.liveSync.get(session.id) - if (existing !== undefined) return existing + private _runLiveSearch(state: ProviderState, session: Session, query: () => Promise): Promise { let snapshot: ReturnType try { snapshot = this._snapshotLive(session) } catch (error: unknown) { return Promise.reject(this._synchronizationError(state, error)) } - const promise = this._enqueue(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return - await state.provider.replaceLive(snapshot) - state.liveIds.add(session.id) + return this._serialize(state, async () => { + await this._synchronize(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + await state.provider.replaceLive(snapshot) + state.liveIds.add(session.id) + }) + return query() }) - state.liveSync.set(session.id, promise) - void promise.finally(() => { - /* v8 ignore next -- a newer invalidation may already own the target slot */ - if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id) - }).catch(() => undefined) - return promise } private _snapshotLive(session: Session): ReturnType { return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) } - private _enqueue(state: ProviderState, operation: () => Promise): Promise { + /** Serialize reconciliation and its provider query as one stable transaction. */ + private _serialize(state: ProviderState, operation: () => Promise): Promise { const next = state.chain.then(operation, operation) state.chain = next.then(() => undefined, () => undefined) - return next.catch((error: unknown) => { + return next + } + + /** Translate only derived-index update failures, never provider query failures. */ + private async _synchronize(state: ProviderState, operation: () => Promise): Promise { + try { + await operation() + } catch (error: unknown) { throw this._synchronizationError(state, error) - }) + } } private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 5249cd22ae..642bfb263f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -103,7 +103,6 @@ class FakeProvider implements SessionSearchProvider { eventRequests: SessionEventSearchSpec[] = [] failNextLive = false failNextPersisted = false - failNextActive = false sessionPage: SessionSearchPage eventPage: SessionSearchPage @@ -125,10 +124,6 @@ class FakeProvider implements SessionSearchProvider { } setPersistedActive(active: boolean): Promise { - if (this.failNextActive) { - this.failNextActive = false - return Promise.reject(new Error('activation failed')) - } this.activeHistory.push(active) return Promise.resolve() } @@ -409,7 +404,7 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) }) - it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => { + it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('coalesce')) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -418,34 +413,40 @@ describe('provider selection and synchronization', () => { let releaseLive!: () => void const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) + const liveStarted = deferred() let replacements = 0 provider.replaceLive = async (snapshot) => { replacements += 1 + liveStarted.resolve() await liveBarrier provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) } const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - await Promise.resolve() + await liveStarted.promise + expect(replacements).toBe(1) releaseLive() await Promise.all([first, second]) - expect(replacements).toBe(1) + expect(replacements).toBe(2) session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) let releaseCorpus!: () => void const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) + const corpusStarted = deferred() let corpusReplacements = 0 provider.replaceLive = async (snapshot) => { corpusReplacements += 1 + corpusStarted.resolve() await corpusBarrier provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) } const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) - await Promise.resolve() + await corpusStarted.promise + expect(corpusReplacements).toBe(1) releaseCorpus() await Promise.all([crossFirst, crossSecond]) - expect(corpusReplacements).toBe(1) + expect(corpusReplacements).toBe(2) let releaseSearch!: () => void const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) @@ -467,6 +468,42 @@ describe('provider selection and synchronization', () => { .rejects.toThrow('search failed') }) + it('holds a provider query stable until later reconciliation can begin', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('stable-query')) + session.append('user/message', { content: [{ type: 'text', text: 'stable' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const queryStarted = deferred() + const releaseQuery = deferred() + provider.searchEvents = async () => { + queryStarted.resolve() + await releaseQuery.promise + return { providerId: provider.id, items: [] } + } + const reconciliationStarted = deferred() + let reconciling = false + provider.setPersistedActive = (active) => { + if (!active) { + reconciling = true + reconciliationStarted.resolve() + } + return Promise.resolve() + } + ctx.sessionQuery.registerSearchProvider(provider) + + const eventSearch = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'stable' }) + await queryStarted.promise + const fullSearch = ctx.sessionQuery.searchSessions({ query: 'stable' }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(reconciling).toBe(false) + + releaseQuery.resolve() + await eventSearch + await reconciliationStarted.promise + expect(reconciling).toBe(true) + await fullSearch + }) + it('reconciles a live removal observed while an older full sync is in flight', async () => { const ctx = await liveContext() const session = ctx.sessions.prepare(SessionId('removed-during-sync')) @@ -563,7 +600,6 @@ describe('provider selection and synchronization', () => { live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const persistenceFiber = await ctx.plugin(TestPersistence) const provider = new FakeProvider() - provider.failNextActive = true provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) ctx.sessionQuery.registerSearchProvider(provider) @@ -583,7 +619,6 @@ describe('provider selection and synchronization', () => { await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') - provider.failNextActive = true await persistenceFiber.dispose() await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.activeHistory.at(-1)).toBe(false) @@ -674,7 +709,7 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') }) - it('types synchronous extractor failures during full-search key construction', async () => { + it('types extractor failures during queued full synchronization', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('throwing-extractor')) session.append('test/note', { note: 'unreachable' }) @@ -714,7 +749,7 @@ describe('provider selection and synchronization', () => { const onUnhandled = (reason: unknown) => { unhandled.push(reason) } process.on('unhandledRejection', onUnhandled) try { - await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) await new Promise((resolve) => { setImmediate(resolve) }) expect(unhandled).toEqual([]) From 8fd68731bab2696abfba3a2d38fd5c47c27811d3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:02:51 +0800 Subject: [PATCH 16/31] fix(session-query): harden provider operation lifecycle --- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/src/index.ts | 4 +- .../session-query/src/provider.ts | 52 ++++++------ .../session-query/tests/session-query.spec.ts | 83 ++++++++++++++++++- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 04d07f5795..c8d083593e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -179,7 +179,7 @@ async listEvents(sessionId: SessionId): Promise async readEvent(request: SessionEventReadRequest): Promise async traceSession(sessionId: SessionId): Promise async traceEvent(sessionId: SessionId, seq: number): Promise -registerSearchProvider(provider: SessionSearchProvider): () => void +registerSearchProvider(provider: SessionSearchProvider): () => Promise registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 12872d689f..2179326c49 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -144,7 +144,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async readEvent(request: SessionEventReadRequest): Promise', 'async traceSession(sessionId: SessionId): Promise', 'async traceEvent(sessionId: SessionId, seq: number): Promise', - 'registerSearchProvider(provider: SessionSearchProvider): () => void', + 'registerSearchProvider(provider: SessionSearchProvider): () => Promise', 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 5dc5aecc34..47977e6f7a 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -22,7 +22,7 @@ Session filters cover id, exact cwd, inclusive creation time, parent id/root, an ## Full-text providers -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Its async disposer removes the provider from selection immediately, lets already accepted transactions finish, and settles after they drain. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index edab597dd5..c07c0b8d05 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -151,9 +151,9 @@ export class SessionQueryService extends Service { /** * Register one full-text provider with effect-scoped disposal. * @param provider - provider and synchronization implementation. - * @returns disposer that unregisters the provider. + * @returns async disposer that immediately unregisters selection and awaits accepted provider work. */ - registerSearchProvider(provider: SessionSearchProvider): () => void { + registerSearchProvider(provider: SessionSearchProvider): () => Promise { return this._providers.register(this.ctx, provider) } diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index c3ec4ad0f4..e4235c6d8a 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -23,7 +23,6 @@ import { filterEventResults, filterSessionResults } from './filters.ts' interface ProviderState { provider: SessionSearchProvider - active: boolean chain: Promise liveIds: Set } @@ -49,26 +48,25 @@ export class SessionProviderCoordinator { * Register one effect-scoped provider. * @param ctx - contributing caller context. * @param provider - provider implementation. - * @returns disposer for the registration. + * @returns async disposer that deselects immediately and drains accepted work. */ - register(ctx: Context, provider: SessionSearchProvider): () => void { + register(ctx: Context, provider: SessionSearchProvider): () => Promise { if (this._providers.has(provider.id)) { throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') } const state: ProviderState = { provider, - active: true, chain: Promise.resolve(), liveIds: new Set(), } const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { this._providers.set(provider.id, state) - yield () => { - state.active = false + yield async () => { this._providers.delete(provider.id) + await state.chain } }.bind(this), 'sessionQuery.registerSearchProvider()') - return () => void dispose() + return async () => { await dispose() } } /** @@ -83,7 +81,7 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeSessionSearch(request) - const work = this._runFullSearch(state, async () => { + const work = this._runFullSearch(state, undefined, async () => { if (exec?.signal?.aborted) throw aborted() const result = await state.provider.searchSessions(normalized, exec) return this._validateSearchPage(state, result, normalized.limit) @@ -113,22 +111,25 @@ export class SessionProviderCoordinator { if (live !== undefined) { work = this._runLiveSearch(state, live, query) } else { - const persistence = await this._corpus().persistenceView() - if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { - throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - work = this._runFullSearch(state, query) + work = this._runFullSearch(state, request.sessionId, query) } return waitFor(work, exec?.signal) } - private _runFullSearch(state: ProviderState, query: () => Promise): Promise { + private _runFullSearch( + state: ProviderState, + requiredSessionId: SessionId | undefined, + query: () => Promise, + ): Promise { const liveSessions = this._corpus().listLive() return this._serialize(state, async () => { await this._synchronize(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return const persistence = await this._corpus().persistenceView() + const missingRequired = requiredSessionId !== undefined + && (persistence === undefined || !persistence.headers.some(header => header.id === requiredSessionId)) + if (missingRequired) { + throw new SessionQueryError(`session "${requiredSessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } if (persistence === undefined) { await state.provider.setPersistedActive(false) } else { @@ -172,8 +173,6 @@ export class SessionProviderCoordinator { } return this._serialize(state, async () => { await this._synchronize(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return await state.provider.replaceLive(snapshot) state.liveIds.add(session.id) }) @@ -272,32 +271,35 @@ export class SessionProviderCoordinator { } function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { - if (signal === undefined) return work + const observed = work.catch((error: unknown) => { throw operationError(error) }) + if (signal === undefined) return observed if (signal.aborted) { // Cancellation supersedes the caller's result, but shared work must still // have a rejection observer when it has already failed synchronously. - void work.catch((_supersededError: unknown) => undefined) + void observed.catch((_supersededError: unknown) => undefined) return Promise.reject(aborted()) } return new Promise((resolve, reject) => { const onAbort = () => { reject(aborted()) } signal.addEventListener('abort', onAbort, { once: true }) - work.then( + observed.then( (value) => { signal.removeEventListener('abort', onAbort) resolve(value) }, (error: unknown) => { signal.removeEventListener('abort', onAbort) - /* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */ - reject(error instanceof Error - ? error - : new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error })) + reject(operationError(error)) }, ) }) } +function operationError(error: unknown): Error { + if (error instanceof Error) return error + return new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }) +} + function aborted(): SessionQueryError { return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 642bfb263f..36a8676640 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -400,10 +400,37 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) .resolves.toMatchObject({ providerId: provider.id }) - dispose() + await dispose() await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) }) + it('deselects immediately and drains accepted work before disposal settles', async () => { + const ctx = await liveContext() + const provider = new FakeProvider() + const queryStarted = deferred() + const releaseQuery = deferred() + provider.searchSessions = async () => { + queryStarted.resolve() + await releaseQuery.promise + return { providerId: provider.id, items: [] } + } + const dispose = ctx.sessionQuery.registerSearchProvider(provider) + + const accepted = ctx.sessionQuery.searchSessions({ query: 'accepted' }) + await queryStarted.promise + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await expect(ctx.sessionQuery.searchSessions({ query: 'future' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + await Promise.resolve() + expect(disposed).toBe(false) + + releaseQuery.resolve() + await expect(accepted).resolves.toMatchObject({ providerId: provider.id }) + await disposal + expect(disposed).toBe(true) + }) + it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('coalesce')) @@ -559,6 +586,60 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') }) + it('cancels a persisted-only event search while persistence listing is blocked', async () => { + const persisted = header('blocked-persisted-target', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) + const listStarted = deferred() + const releaseList = deferred() + TestPersistence.onList = listStarted.resolve + TestPersistence.listBarrier = releaseList.promise + const ctx = await liveContext() + const persistenceFiber = await ctx.plugin(TestPersistence) + await listStarted.promise + const provider = new FakeProvider() + const disposeProvider = ctx.sessionQuery.registerSearchProvider(provider) + const controller = new AbortController() + + const pending = ctx.sessionQuery.searchEvents( + { sessionId: persisted.id, query: 'target' }, + { signal: controller.signal }, + ) + controller.abort() + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + expect(provider.eventRequests).toEqual([]) + + releaseList.resolve() + await disposeProvider() + await persistenceFiber.dispose() + TestPersistence.listBarrier = undefined + TestPersistence.onList = undefined + }) + + it('normalizes non-Error query rejections and preserves Error identity', async () => { + const ctx = await liveContext() + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const signals = [undefined, new AbortController().signal] + + for (const [index, signal] of signals.entries()) { + const exec = signal === undefined ? undefined : { signal } + const identity = new Error(`query failure ${index}`) + provider.searchSessions = () => Promise.reject(identity) + const preserved = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) + .then(() => undefined, (error: unknown) => error) + expect(preserved).toBe(identity) + + const rejection = { index } + // Deliberately violate the Promise convention to test the provider boundary. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + provider.searchSessions = () => Promise.reject(rejection) + const normalized = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) + .then(() => undefined, (error: unknown) => error) + expect(normalized).toBeInstanceOf(SessionQueryError) + expect(normalized).toMatchObject({ code: 'SESSION_QUERY_PROVIDER_ERROR', cause: rejection }) + } + }) + it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { const ctx = await liveContext() const first = new FakeProvider('first') From ad32c57e724e159291d132233d3c330ce9173ee0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:20:35 +0800 Subject: [PATCH 17/31] refactor(session-query): narrow phase one to exact reads --- docs/architecture.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 10 +- docs/cordis-catalog/events.md | 24 +- docs/cordis-catalog/services.md | 15 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/persistence.md | 14 - docs/core-data-structures/session-query.md | 229 +--- docs/event-producer-consumer.md | 6 +- docs/rfc/INDEX.md | 4 +- .../2026-07-10-session-query-service.md | 54 +- ...026-07-10-sqlite-session-query-provider.md | 49 +- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 111 +- packages/core/session/README.md | 6 +- packages/core/session/src/index.ts | 15 - packages/core/session/tests/session.spec.ts | 37 - .../session-persistence/README.md | 2 - .../session-persistence/src/coordinator.ts | 60 +- .../session-persistence/src/index.ts | 23 - .../tests/coordinator-contract.ts | 68 -- packages/session-query/README.md | 6 +- .../session-query/session-query/README.md | 45 +- .../session-query/session-query/package.json | 2 +- .../session-query/session-query/src/config.ts | 34 +- .../session-query/session-query/src/corpus.ts | 260 ++--- .../session-query/src/extraction.ts | 254 ---- .../session-query/src/filters.ts | 123 -- .../session-query/session-query/src/index.ts | 177 +-- .../session-query/src/provider.ts | 310 ----- .../session-query/src/tracing.ts | 158 --- .../session-query/session-query/src/types.ts | 260 +---- .../session-query/tests/session-query.spec.ts | 1028 +++-------------- scripts/gen-doc-graphs.ts | 15 +- scripts/type-equiv.manifest.json | 23 - 35 files changed, 396 insertions(+), 3036 deletions(-) delete mode 100644 packages/session-query/session-query/src/extraction.ts delete mode 100644 packages/session-query/session-query/src/filters.ts delete mode 100644 packages/session-query/session-query/src/provider.ts delete mode 100644 packages/session-query/session-query/src/tracing.ts diff --git a/docs/architecture.md b/docs/architecture.md index 8a9cb0a4f2..2e113f956b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live/persisted session retrieval and search-provider coordination | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | ## Event diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dc2f0a05bc..c57c130bf4 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -25,7 +25,7 @@ flowchart LR pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] - svc_sessionQuery["ctx.sessionQuery
Session retrieval read model"] + svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -159,7 +159,7 @@ flowchart LR | `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), [`session-query`](../packages/session-query/session-query), [`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), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | | `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-cordis`](../packages/cordis/tool-cordis), [`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/ui/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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d008878b6a..532f52af77 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -488,20 +488,14 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:5 Requires: `sessions` ```ts config-catalog -/** Configuration for the provider-neutral session-query service. */ +/** Configuration for exact session-query reads. */ export interface Config { - /** Explicit provider id; omitted auto-selects exactly one usable provider. */ - searchProvider?: string - /** Default search result page size. Defaults to 20. */ - defaultLimit?: number - /** Maximum accepted search page size. Defaults to 100. */ - maxLimit?: number /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } ``` -Source: [`packages/session-query/session-query/src/config.ts:17`](../packages/session-query/session-query/src/config.ts) +Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) ## `@deepseek-ai/dsh-stdio-agent` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 42be61a24e..a9dfb7e05c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -237,7 +237,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -247,27 +247,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:65`](../../packages/core/session/src/index.ts) - -### `session/persisted` — parallel - -A persistence backend committed a canonical session-log change. This is an observe-only notification for derived read models: the durable write has already succeeded, and listener failures are contained rather than propagated into append, load, flush, or teardown. - -```ts cordis-catalog -'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void -``` - -Source: [`packages/session-persistence/session-persistence/src/index.ts:50`](../../packages/session-persistence/session-persistence/src/index.ts) - -### `session/removed` — parallel - -A session left the live store. The header is snapshotted after the store entry is removed; listener failures are contained and cannot break the owning fiber's teardown. - -```ts cordis-catalog -'session/removed'(header: SessionHeader): Promise | void -``` - -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c8d083593e..c079fbed01 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -167,26 +167,19 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:125`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` -Session-history retrieval and provider coordination service. +Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise async readEvent(request: SessionEventReadRequest): Promise -async traceSession(sessionId: SessionId): Promise -async traceEvent(sessionId: SessionId, seq: number): Promise -registerSearchProvider(provider: SessionSearchProvider): () => Promise -registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void -registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void -searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> -searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise> ``` -Source: [`packages/session-query/session-query/src/index.ts:59`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -204,7 +197,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:413`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6a31eddd70..c0e1213731 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [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` | -| [session-query.md](session-query.md) | the retrieval seam: logical session/event records, filters, traces, search pages, extractors, and provider synchronization types | +| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | | [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 | diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 012e606f48..4dbb8fb2af 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -73,20 +73,6 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. -## `SessionPersistedChange` — committed-log notification range - -The observe-only `session/persisted` event carries the canonical header and the committed range. A repair can report `toSeq < fromSeq` when it only removes a torn fragment. - -Source: [`packages/session-persistence/session-persistence/src/index.ts`](../../packages/session-persistence/session-persistence/src/index.ts) - -```ts type-equiv -export interface SessionPersistedChange { - kind: 'append' | 'repair' - fromSeq: number - toSeq: number -} -``` - ## The backends Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index b824e99f1e..ded8ca3f7e 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,12 +1,12 @@ # Session Query -The provider-neutral retrieval seam over live and optionally persisted sessions. The [package contract](../../packages/session-query/session-query) owns resolution, lifecycle, synchronization, and error behavior; this page catalogs the public data exchanged by callers, extractors, and search providers. +Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) -## Logical records and filters +## Logical records -`SessionRecord` exposes source availability independently from its live-preferred header. `SessionEventRecord` classifies every raw event against the folded surface. +`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. ```ts type-equiv export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' @@ -30,137 +30,9 @@ export interface SessionEventRecord { } ``` -Filters are serializable discriminated specs. Each spec is one transform in a chain; the literal types below are shared by in-memory filtering and provider pre-ranking requests. +## Bounded event reads -```ts type-equiv -export interface SessionQueryRange { - from?: number - to?: number -} -``` - -```ts type-equiv -export type SessionResultFilter = - | { kind: 'id'; values: readonly SessionId[] } - | { kind: 'cwd'; values: readonly (string | null)[] } - | { kind: 'created-at'; range: SessionQueryRange } - | { kind: 'parent'; values: readonly (SessionId | null)[] } - | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } -``` - -```ts type-equiv -export type SessionEventResultFilter = - | { kind: 'seq'; range: SessionQueryRange } - | { kind: 'time'; range: SessionQueryRange } - | { kind: 'type'; values: readonly SessionEventType[] } - | { kind: 'surface'; values: readonly SessionEventSurface[] } -``` - -## Search requests and pages - -Both scopes use the same opaque-cursor page envelope. Session hits carry exactly one best event; event hits add only a plain-text snippet to the lightweight record. - -```ts type-equiv -export interface SessionQueryExecContext { - readonly signal?: AbortSignal -} -``` - -```ts type-equiv -export type SessionSearchProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } -``` - -```ts type-equiv -export interface SessionSearchPageRequest { - limit?: number - cursor?: string -} -``` - -```ts type-equiv -export interface SessionSearchRequest extends SessionSearchPageRequest { - query: string - sessionFilters?: readonly SessionResultFilter[] - eventFilters?: readonly SessionEventResultFilter[] -} -``` - -```ts type-equiv -export interface SessionEventSearchRequest extends SessionSearchPageRequest { - sessionId: SessionId - query: string - filters?: readonly SessionEventResultFilter[] -} -``` - -The service resolves caller requests before crossing the provider seam, so provider implementations always receive a validated page limit. - -```ts type-equiv -export interface SessionSearchSpec extends SessionSearchRequest { - limit: number -} -``` - -```ts type-equiv -export interface SessionEventSearchSpec extends SessionEventSearchRequest { - limit: number -} -``` - -```ts type-equiv -export interface SessionEventSearchHit extends SessionEventRecord { - snippet: string -} -``` - -```ts type-equiv -export interface SessionSearchHit extends SessionRecord { - bestMatch: SessionEventSearchHit -} -``` - -```ts type-equiv -export interface SessionSearchPage { - providerId: string - items: readonly T[] - nextCursor?: string -} -``` - -## Errors - -The service exposes a closed machine-routable error taxonomy; messages and causes provide detail but do not add codes. - -```ts type-equiv -export type SessionQueryErrorCode = - | 'SESSION_QUERY_ABORTED' - | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' - | 'SESSION_QUERY_DUPLICATE_PROVIDER' - | 'SESSION_QUERY_EVENT_NOT_FOUND' - | 'SESSION_QUERY_INDEX_FAILED' - | 'SESSION_QUERY_INVALID_CONFIG' - | 'SESSION_QUERY_INVALID_EXTRACTOR' - | 'SESSION_QUERY_INVALID_FILTER' - | 'SESSION_QUERY_INVALID_LIMIT' - | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_QUERY' - | 'SESSION_QUERY_INVALID_SURFACE' - | 'SESSION_QUERY_INVALID_WINDOW' - | 'SESSION_QUERY_PERSISTENCE_FAILED' - | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'SESSION_QUERY_PROVIDER_ERROR' - | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' - | 'SESSION_QUERY_SESSION_NOT_FOUND' - | 'SESSION_QUERY_SOURCE_CONFLICT' -``` - -## Event reads and traces - -An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. +The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. ```ts type-equiv export interface SessionEventReadRequest { @@ -173,7 +45,7 @@ export interface SessionEventReadRequest { ```ts type-equiv export interface SessionEventWindow { - session: SessionRecord + session: SessionHeader target: SessionEvent events: SessionEvent[] startSeq: number @@ -181,84 +53,17 @@ export interface SessionEventWindow { } ``` -```ts type-equiv -export interface SessionLineageNode { - session: SessionRecord - children: SessionLineageNode[] -} -``` +## Errors + +The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. ```ts type-equiv -export interface SessionLineageTrace { - target: SessionRecord - parents: SessionRecord[] - root?: SessionRecord - unresolvedParentId?: SessionId - children: SessionLineageNode[] -} -``` - -```ts type-equiv -export interface SessionEventTrace { - target: SessionEventRecord - shadowedBy?: number - replacementChain: number[] - shadows: number[] - references: number[] - referencedBy: number[] -} -``` - -## Extraction and provider synchronization - -Custom extractors are keyed by declaration-merged event or content discriminants and carry stable cache-invalidation versions. Providers receive complete event documents grouped into independently replaceable persisted and live snapshots. - -```ts type-equiv -export interface SessionEventTextExtractor { - version: string - extract(event: SessionEvent): readonly string[] -} -``` - -```ts type-equiv -export interface SessionContentTextExtractor { - version: string - extract(block: ContentBlockMap[K]): readonly string[] -} -``` - -```ts type-equiv -export interface SessionIndexDocument extends SessionEventRecord { - text: string -} -``` - -```ts type-equiv -export interface SessionIndexSnapshot { - session: SessionRecord - fingerprint: string - documents: readonly SessionIndexDocument[] -} -``` - -```ts type-equiv -export interface SessionPersistedIndexEntry { - sessionId: SessionId - fingerprint: string -} -``` - -```ts type-equiv -export interface SessionSearchProvider { - readonly id: string - status(): SessionSearchProviderStatus - persistedInventory(): Promise - setPersistedActive(active: boolean): Promise - replacePersisted(snapshot: SessionIndexSnapshot): Promise - removePersisted(sessionId: SessionId): Promise - replaceLive(snapshot: SessionIndexSnapshot): Promise - removeLive(sessionId: SessionId): Promise - searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> - searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> -} +export type SessionQueryErrorCode = + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23e3844375..acbe700965 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,10 +24,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../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:55`](../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:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../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:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4c77469f63..a4c6c213af 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,7 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | -| [SQLite FTS5 session-query provider](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | +| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | ### Simplification @@ -68,7 +68,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | -| [Provider-neutral session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | +| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index eeb3a13fc7..7e13256669 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -1,61 +1,41 @@ -# RFC: Provider-neutral session query service +# RFC: Exact session query service Status: implemented ## Problem -Session logs contain the harness's durable working memory, but the existing services expose them only as live objects or backend-specific persisted records. Consumers that want history search, compacted-event recall, lineage inspection, or another agent's status otherwise have to choose a storage backend, duplicate live-versus-persisted precedence, and reconstruct surface provenance independently. Live state also advances between persistence checkpoints, so treating durable storage as the only query source makes current-turn reads stale. +Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. -Search is only one operation in that read model. Metadata filtering must compose without another database round trip, event and session lineage need deterministic graph semantics, and an event read must return exact canonical content rather than a search snippet. Folding all of those responsibilities into one SQLite package would make storage technology the public API and would prevent live-only deployments from using the non-search capabilities. +Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a trusted provider-neutral read model over one logical corpus: live `SessionStore` entries plus an optional, dynamically mounted `SessionPersistence` service. Matching ids resolve to one record. Live events take precedence because they include appends after the latest checkpoint; the record still exposes independent `live` and `persisted` flags. The service compares immutable headers and fails with a typed source-conflict error when the two sources cannot represent the same session. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. -The service owns source observation, reconciliation, precedence, cloning, filters, tracing, extraction, and provider selection. It exposes lightweight session and event records, bounded exact-event reads, complete known session lineage, event surface/provenance traces, and two full-text scopes. A search backend owns only indexing, ranking, snippets, cursors, and backend-specific query validation. +The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. -Persistence is optional. Live-only reads and provider synchronization work without it. Unmounting persistence hides the provider's durable base rather than deleting derived cache rows, so remounting can reuse fingerprints. An installed but unreadable backend fails cross-session operations; a read of a known live session remains independent of that failure. +An exact target read first checks the live store and snapshots the live header and event log. This path never consults persistence, so a failing durable backend cannot make known live history unreadable. With no live target, the service lists current persistence metadata, proves the id exists, loads it, and rejects a list/load header mismatch. All returned headers and events cross one structured-clone boundary. -## Surface and lineage semantics +## Surface semantics -`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seq range. Session-query derives `current`, `shadowed`, and `log-only` classifications and replacement chains from that result, so query and model-history derivation cannot disagree about positional replacement semantics. +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` uses that result to classify every raw event as `current`, `shadowed`, or `log-only`, so inspection cannot disagree with model-history derivation about positional replacement semantics. -Event traces accept any raw event. They return direct `sourceEventSeqs` references, reverse references, nodes directly shadowed by a replacement, its immediate replacer, and the transitive replacement chain toward the current surface. Related content is deliberately not embedded; exact content remains the job of the bounded event read. - -Session traces walk parents nearest-first. A complete chain reports its root; a partial corpus reports the first unresolved parent id. Descendants form a complete known tree ordered by creation time and id. A cycle connected to the target is an invalid lineage error rather than a truncated result. - -## Filters and public records - -Serializable discriminated filter specs cover session identity, cwd, creation time, parent/root, availability, event seq/time/type, and surface status. Alternatives within one spec are OR; specs in a supplied array are AND. The exported generic transforms are pure, preserve order and item identity, and work on base records or richer hits. Search requests accept the same specs before ranking. Applying a transform to one materialized page never triggers a refill. - -Public records are intentionally small. `SessionRecord` carries a cloned header and source flags. `SessionEventRecord` carries session id, seq, type, time, and surface status. Search adds a plain snippet to event hits and exactly one best event to session hits; numeric provider scores remain private. Search pages default to 20 and reject limits above 100. Exact event reads default to no neighbors and cap each side with the configurable `readWindowMax`, default 50. - -## Lifecycle notifications - -Two observe-only Cordis notifications keep derived read models current without joining the write transaction. `session/removed` fires after a live entry leaves `SessionStore`. `session/persisted` fires only after an ordinary append or load-time repair commits and carries the affected seq range. Both snapshot their payloads and contain synchronous dispatch errors and rejected listeners, so observers cannot fail session teardown or durability. - -A persistence load preserves an existing live owner in coordinator state. HMR adoption of a torn durable prefix truncates only the uncommitted fragment while the live session remains authoritative; it does not publish a repair notification or synthesize an interrupted turn mid-turn. A later real append produces the ordinary committed notification. - -## Provider and extractor contracts - -A selected search provider receives separate persisted-base and live-override operations. Persisted reconciliation begins inactive, compares the provider inventory with SHA-256 fingerprints over canonicalized header/events and relevant extractor versions, replaces only changed sessions, removes proven-stale rows, and then activates the base. Live snapshots always replace the matching override; removal reveals an active persisted base. Search waits for relevant queued reconciliation, with corpus scope for session search and target scope for a live event search. A failed update stays retryable and fails affected searches with a typed derived-index error without affecting canonical writes. Caller cancellation stops waiting and reaches provider query work through `AbortSignal`. - -Core extractors cover semantic messages, reasoning, tools, todos, blocked prompts, context and steering, and error/status detail. Chunks, request headers, and structural events add no document. Declaration-merged event and content-block owners can install one effect-scoped extractor per type with a stable version; unknown types stay non-searchable. +`readEvent()` returns the complete target plus raw neighbors by contiguous seq. `before` and `after` default to zero and are independently bounded by `readWindowMax`, default 50. The result carries a cloned `SessionHeader`, not a source-availability record, because determining a live target's persisted flag would violate the guarantee that live exact reads do not depend on persistence health. ## Security boundary -The service is context-wide trusted infrastructure, not an authorization layer. A model-facing history tool or human UI applies explicit caller/session scope before invoking cross-session operations. This decision exposes no unscoped model tool and changes no transcript or snapshot surface. +The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. This phase adds no model-facing tool and changes no transcript or snapshot surface. ## Alternatives considered -- **Put all query behavior in a SQLite implementation** — rejected because filters, exact reads, source precedence, lineage, and surface provenance are storage-independent, and live-only deployments still need them. It would also let backend details become the public service contract. -- **Query only persisted sessions** — rejected because persistence checkpoints occur at turn boundaries; a current live session would be stale precisely when an agent inspects its latest work. -- **Mirror every live append into persistence before querying** — rejected because query observation must not add durability latency or change the turn checkpoint contract. The live override is an ephemeral derived layer. -- **Express every chained filter as SQL** — rejected because post-filters operate over already materialized pages and must preserve item identity and caller-chosen composition. Serializable pure transforms also remain usable without a search provider. -- **Make session-query part of the compaction capability** — rejected because retrieval reads all session structure and has consumers beyond recall; compaction is one producer of replacement provenance, not the owner of the read model. +- **Put logical-corpus resolution directly in every consumer** — rejected because source precedence, conflicts, optional-service lifecycle, cloning, and surface classification are shared correctness rules. +- **Query only persistence** — rejected because checkpoints can lag the current live log. +- **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. +- **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. +- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. ## Consequences -Consumers gain one coherent API for current and durable history, deterministic traces, and backend-neutral search. Derived index failures and optional persistence are isolated from canonical session writes, and unchanged persisted sessions can reuse provider rows across restarts. +Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. -The service carries non-trivial reconciliation state and performs canonical log loads to validate fingerprints. Cross-session search intentionally waits for whole-corpus synchronization, and live precedence means providers must implement a two-layer model. Authorization remains the responsibility of future consumers. Full-text search is unavailable until an implementation package registers a provider; that implementation is intentionally outside this decision's package. +Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index 08c1c6fc75..acfdf23bee 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -1,50 +1,51 @@ -# RFC: SQLite FTS5 session-query provider +# RFC: SQLite FTS5 session search Status: proposed ## Problem -The provider-neutral session-query service defines full-text scopes and synchronization but deliberately ships no index. A first backend must search semantic event documents across large persisted histories without rebuilding unchanged sessions at every process start, while keeping unflushed live overrides current and disposable. It also needs deterministic ranking and pagination semantics strong enough for model tools and UI clients to continue a result set safely. +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. -Using the canonical session-persistence database directly would couple two failure domains and schemas: query rows are derived and rebuildable, while session logs are authoritative. A query schema reset, corrupt index, or experimental tokenizer must never endanger durable conversation history. +Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. ## Proposal -Add an `@deepseek-ai/dsh-session-query-sqlite` implementation in a separate phase-two pull request after the provider-neutral phase is complete. It will register one `SessionSearchProvider` on `ctx.sessionQuery` and own a separate derived SQLite database. Persisted event documents survive provider restarts; live overrides remain connection-local and disappear when the provider closes. +Add `@deepseek-ai/dsh-session-query-sqlite` beside the exact-read package. The package will expose a search service or extend the family with the smallest API required by its actual consumers; phase one does not pre-commit a provider-registration protocol. It will depend on `ctx.sessions` and optional `ctx.sessionPersistence`, own a separate derived SQLite database, and reuse the canonical `foldSurface()` classification. -The provider will use SQLite FTS5 with the trigram tokenizer. A query splits on whitespace and requires every term. Terms shorter than three characters fail with a typed provider error rather than silently changing matching semantics. Each searchable event is one document, including current, shadowed, and log-only states by default. Event search ranks documents within one session; session search groups by session and ranks it by exactly one strongest matching event. Ties are deterministic, public hits contain plain-text snippets, and numeric FTS scores remain internal. +The implementation owns one serialized reconciliation/DB transaction state machine. A transaction observes authoritative persisted metadata and live snapshots, extracts semantic documents, updates derived tables, advances relevant cursor generations, and executes or enables the corresponding query. No second service maintains parallel fingerprints, dirty flags, live-id sets, or invalidation generations. -## Storage and reconciliation +Persisted documents survive restarts. Live overrides are connection-local and shadow the persisted rows for the same session, then disappear when the live owner or database closes. The derived database remains separate from canonical persistence so index reset, corruption, tokenizer changes, and schema churn cannot endanger durable conversation logs. -The database path, journal mode, page/result limits, and snippet length are validated configuration. Durable tables store provider schema version, persisted-session fingerprints, lightweight session metadata, event metadata, text, and the FTS virtual table. A provider-schema mismatch is the exceptional full reset; ordinary startup calls `persistedInventory()` and lets the service replace only new or changed sessions and remove canonical deletions. +## Search semantics to decide with implementation -The live layer uses temporary or connection-local tables with the same searchable shape. A live snapshot shadows every persisted document for that session. Removing the override reveals the active persisted base. `setPersistedActive(false)` excludes durable rows from results without deleting their fingerprint cache. Reopening the database proves that persisted rows remain and live rows do not. +The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. -## Query and cursor semantics +Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. -Search request filters compile to parameterized metadata predicates before FTS ranking. Query terms are escaped as data, never interpolated into FTS syntax. Snippets are plain text with bounded length and no provider-specific markup contract. +Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. -Opaque cursors bind to the normalized request shape and a generation. Session-search cursors bind to the global logical-corpus generation. Event-search cursors bind only to the target session generation. A relevant change makes the cursor stale and produces a typed error; unrelated session changes do not invalidate an inner-session cursor. Stable tie fields are encoded after rank so resumed pages neither duplicate nor skip hits. +## Extraction and reconciliation -Provider update operations are transactional. An index write failure leaves the prior committed generation queryable only after the owning service has successfully retried the dirty update; affected searches fail rather than returning a knowingly stale page. Abort signals interrupt waits and SQLite query work where the runtime permits. +The package starts with first-party semantic extraction for messages, reasoning, tool calls/results, blocked prompts, context, steering, todos, and error/status detail. Structural events and stream chunks contribute no document. Unknown declaration-merged event/content types remain non-searchable unless a real extension consumer demonstrates the need for a public extractor registry. + +Reconciliation may use stable fingerprints to avoid rewriting unchanged persisted sessions, but the database package owns their calculation and storage. It must never report a row current when source observation or extraction failed. Provider-schema mismatch may reset only the derived database; ordinary source changes use transactional upsert/delete. Mounted but unreadable persistence fails affected searches without affecting canonical writes or known live exact reads. ## Alternatives considered -- **Use the session-persistence SQLite database and add FTS tables there** — rejected because derived-index schema churn, resets, and corruption recovery must not share the authoritative log's transaction or failure boundary. -- **Persist live overrides immediately** — rejected because live events are not canonical until the existing persistence checkpoint commits. Ephemeral overlay rows preserve read-your-writes without inventing a second durability path. -- **Use the default FTS5 unicode tokenizer** — rejected for the first backend because substring-oriented history recall is a core use case. Trigram search gives predictable mid-token matching at the accepted cost of rejecting sub-three-character terms. -- **Return raw BM25 scores** — rejected because scores are provider-specific and unstable across corpus changes. Ranking is observable; numeric scale is not part of the service API. -- **Keep cursors valid across index changes** — rejected because rank and grouping can move after a relevant write, making continued pages duplicate or omit hits. +- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema/reset/failure boundary. +- **Reintroduce phase-one provider coordination** — rejected because there is one planned implementation and no evidence for a stable multi-provider seam. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. +- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. ## Acceptance criteria -- Restart tests prove an unchanged persisted fingerprint performs no FTS replacement, while new, changed, and deleted sessions reconcile correctly. -- Reopening proves persisted rows survive, live rows disappear, removing a live override reveals its persisted base, and the provider works with no persistence service. -- Tests cover both search scopes, all metadata filters, surface defaults, snippets, AND-term escaping, short-term rejection, deterministic ties, pagination, request-bound cursors, scoped stale generations, cancellation, and recovery after a failed index update. -- A provider-schema mismatch resets only the derived database. Normal source changes never trigger a full reset. -- A keyless end-to-end restart test combines a real persistence backend with the real SQLite query provider. -- The implementation, package wiring, and tests land only in the separate phase-two pull request; phase one contains this proposal but no SQLite query code. +- Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. +- Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. +- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. +- A schema mismatch resets only the derived database. +- A keyless end-to-end test combines a real persistence backend with the real SQLite search package. +- The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. ## Risks -Trigram indexes use more space than word-token indexes, and loading canonical logs to recompute fingerprints still has startup I/O cost even when FTS replacement is skipped. FTS5 ranking and snippet behavior can differ across SQLite runtime versions, so deterministic tie fields and provider-owned snippet tests must pin only the contract the package controls. A global generation makes cross-session cursors conservative: any corpus change invalidates them. The separate derived database adds configuration and lifecycle work, but it preserves the authoritative store's safety boundary. +A single owner is simpler but initially less reusable than a provider-neutral seam. That is intentional: a second real backend can reveal what to extract. SQLite runtime differences can affect FTS ranking and snippets, so tests must pin only contract-controlled ordering and presentation. The separate database adds configuration and lifecycle work, but preserves the canonical store's safety boundary. diff --git a/packages/README.md b/packages/README.md index 8bc5c07637..da3e61e3a5 100644 --- a/packages/README.md +++ b/packages/README.md @@ -23,7 +23,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 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 | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, filters, tracing, and full-text provider seam | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | 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 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2179326c49..545be42957 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -137,18 +137,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionQuery', - summary: 'Session-history retrieval and provider coordination service.', + summary: 'Live-preferred logical-corpus and exact-event read service.', methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', - 'async traceSession(sessionId: SessionId): Promise', - 'async traceEvent(sessionId: SessionId, seq: number): Promise', - 'registerSearchProvider(provider: SessionSearchProvider): () => Promise', - 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', - 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', - 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', - 'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise>', ], }, { @@ -337,18 +330,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/flush\'(session: Session): Promise | void', summary: 'Awaited durability checkpoint.', }, - { - name: 'session/persisted', - mode: 'parallel', - signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise | void', - summary: 'A persistence backend committed a canonical session-log change.', - }, - { - name: 'session/removed', - mode: 'parallel', - signature: '\'session/removed\'(header: SessionHeader): Promise | void', - summary: 'A session left the live store.', - }, { name: 'subagent/end', mode: 'emit', @@ -705,10 +686,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, - { - name: 'SessionContentTextExtractor', - declaration: 'export interface SessionContentTextExtractor {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -725,41 +702,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventRecord', declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', }, - { - name: 'SessionEventResultFilter', - declaration: 'export type SessionEventResultFilter = {\n kind: \'seq\';\n range: SessionQueryRange;\n} | {\n kind: \'time\';\n range: SessionQueryRange;\n} | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n};', - }, - { - name: 'SessionEventSearchHit', - declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', - }, - { - name: 'SessionEventSearchRequest', - declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', - }, - { - name: 'SessionEventSearchSpec', - declaration: 'export interface SessionEventSearchSpec extends SessionEventSearchRequest {\n limit: number;\n}', - }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', }, - { - name: 'SessionEventTextExtractor', - declaration: 'export interface SessionEventTextExtractor {\n version: string;\n extract(event: SessionEvent): readonly string[];\n}', - }, - { - name: 'SessionEventTrace', - declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n shadowedBy?: number;\n replacementChain: number[];\n shadows: number[];\n references: number[];\n referencedBy: number[];\n}', - }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', }, { name: 'SessionEventWindow', - declaration: 'export interface SessionEventWindow {\n session: SessionRecord;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', + declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', }, { name: 'SessionForkSource', @@ -773,70 +726,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, - { - name: 'SessionIndexDocument', - declaration: 'export interface SessionIndexDocument extends SessionEventRecord {\n text: string;\n}', - }, - { - name: 'SessionIndexSnapshot', - declaration: 'export interface SessionIndexSnapshot {\n session: SessionRecord;\n fingerprint: string;\n documents: readonly SessionIndexDocument[];\n}', - }, - { - name: 'SessionLineageNode', - declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n children: SessionLineageNode[];\n}', - }, - { - name: 'SessionLineageTrace', - declaration: 'export interface SessionLineageTrace {\n target: SessionRecord;\n parents: SessionRecord[];\n root?: SessionRecord;\n unresolvedParentId?: SessionId;\n children: SessionLineageNode[];\n}', - }, - { - name: 'SessionPersistedIndexEntry', - declaration: 'export interface SessionPersistedIndexEntry {\n sessionId: SessionId;\n fingerprint: string;\n}', - }, - { - name: 'SessionQueryExecContext', - declaration: 'export interface SessionQueryExecContext {\n readonly signal?: AbortSignal;\n}', - }, - { - name: 'SessionQueryRange', - declaration: 'export interface SessionQueryRange {\n from?: number;\n to?: number;\n}', - }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', }, - { - name: 'SessionResultFilter', - declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | {\n kind: \'created-at\';\n range: SessionQueryRange;\n} | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly (\'live\' | \'persisted\')[];\n};', - }, - { - name: 'SessionSearchHit', - declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', - }, - { - name: 'SessionSearchPage', - declaration: 'export interface SessionSearchPage {\n providerId: string;\n items: readonly T[];\n nextCursor?: string;\n}', - }, - { - name: 'SessionSearchPageRequest', - declaration: 'export interface SessionSearchPageRequest {\n limit?: number;\n cursor?: string;\n}', - }, - { - name: 'SessionSearchProvider', - declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise>;\n}', - }, - { - name: 'SessionSearchProviderStatus', - declaration: 'export type SessionSearchProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'misconfigured\' | \'unavailable\';\n};', - }, - { - name: 'SessionSearchRequest', - declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', - }, - { - name: 'SessionSearchSpec', - declaration: 'export interface SessionSearchSpec extends SessionSearchRequest {\n limit: number;\n}', - }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 89d2ffdf03..4de99ad961 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -25,7 +25,11 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Events -The generated [Cordis event catalog](../../../docs/cordis-catalog/events.md) is the signature reference. `session/removed` is an observe-only notification emitted with a cloned header after the entry leaves the store; listener failures cannot fail owner teardown. +| Event | Mode | Purpose | +|---|---|---| +| `session/created` | emit | A session was created | +| `session/event` | emit | An event was appended (sync, fire-and-forget) | +| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | ### Class: `Session` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 88cb198c49..ebbf28d63e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -37,14 +37,6 @@ declare module 'cordis' { * @mode emit */ 'session/created'(session: Session): void - /** - * A session left the live store. The header is snapshotted after the store - * entry is removed; listener failures are contained and cannot break the - * owning fiber's teardown. - * @param header - immutable identity and lineage of the removed session. - * @mode parallel - */ - 'session/removed'(header: SessionHeader): Promise | 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. @@ -509,15 +501,8 @@ export class SessionStore extends Service { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(session.id, session) return () => { - if (this.store.get(session.id) !== session) return session.onAppend = undefined this.store.delete(session.id) - const header = structuredClone(session.header) - void Promise.resolve() - .then(() => this.ctx.parallel('session/removed', header)) - .catch((error: unknown) => { - this.ctx.logger.warn(`session store: session/removed listener failed for "${session.id}": ${String(error)}`) - }) } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e51ed923df..f338b635f3 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -350,43 +350,6 @@ describe('SessionStore', () => { expect(observed).toBe(0) }) - it('announces a cloned header only after the session leaves the store', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const observations: Array<{ id: string; live: boolean }> = [] - ctx.on('session/removed', (header) => { - observations.push({ id: header.id, live: ctx.sessions.get(header.id) !== undefined }) - header.createdAt = -1 - }) - const session = ctx.sessions.prepare(SessionId('removed'), { meta: { createdAt: 7 } }) - const detach = ctx.sessions.enter(session) - - detach() - await Promise.resolve() - await Promise.resolve() - - expect(observations).toEqual([{ id: 'removed', live: false }]) - expect(session.header.createdAt).toBe(7) - // A repeated disposer cannot remove or announce a later same-id owner. - const replacement = ctx.sessions.create(SessionId('removed')) - detach() - expect(ctx.sessions.get(replacement.id)).toBe(replacement) - expect(observations).toHaveLength(1) - }) - - it('contains rejected session/removed listeners during teardown', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) - const session = ctx.sessions.prepare(SessionId('contained')) - const detach = ctx.sessions.enter(session) - - expect(detach).not.toThrow() - await Promise.resolve() - await Promise.resolve() - expect(ctx.sessions.get(session.id)).toBeUndefined() - }) - it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 557b968609..8bd3fed568 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -26,8 +26,6 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of `PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). -After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn. - The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b04387dab1..0180999842 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts' +import { assertSerializable, seedCoversPrefix } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -229,13 +229,13 @@ export class PersistenceCoordinator { // event inside it — before the op runs would otherwise have those changes // persisted. The clone is taken synchronously (at call time). const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this._appendCore(id, batch)) + return this.serialize(id, () => this.appendCore(id, batch)) } - private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { if (events.length === 0) return let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load + if (state === undefined) state = await this.adopt(id) // calls loadCore, not load // Contiguity contract: each event's seq must continue the stored log. for (const [i, event] of events.entries()) { @@ -247,14 +247,8 @@ export class PersistenceCoordinator { await this.backend.appendBatch(state.meta, events, state.materialized) // The durable write is the transaction: mark materialized + advance the // cursor as soon as it commits (uniform across backends). - const fromSeq = state.cursor state.materialized = true state.cursor += events.length - this._notifyPersisted(state.meta, { - kind: 'append', - fromSeq, - toSeq: state.cursor - 1, - }) } /** @@ -265,10 +259,10 @@ export class PersistenceCoordinator { * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this._loadCore(id)) + return this.serialize(id, () => this.loadCore(id)) } - private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored @@ -287,21 +281,10 @@ export class PersistenceCoordinator { // there is no state-path ordering dependency (uniform across backends). if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) - this._notifyPersisted(meta, { - kind: 'repair', - fromSeq: events.length, - toSeq: balanced.length - 1, - }) } - const owner = this.states.get(id)?.owner - // The state keeps its OWN copy of the meta; preserve a live owner already - // bound to the id so a read-side load cannot downgrade adoption state. - this.states.set(id, { - meta: { ...meta }, - cursor: balanced.length, - materialized: true, - ...owner !== undefined ? { owner } : {}, - }) + // The state keeps its OWN copy of the meta; the returned value is separate so + // a consumer mutating loaded.meta cannot corrupt the backend's metadata. + this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) return { meta, events: balanced } } @@ -331,11 +314,11 @@ export class PersistenceCoordinator { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise { - // _loadCore (NOT load) — adopt runs inside an already-serialized op, so + // loadCore (NOT load) — adopt runs inside an already-serialized op, so // re-entering the chain via the public load() would deadlock. - await this._loadCore(id) + await this.loadCore(id) const state = this.states.get(id) - /* v8 ignore next -- _loadCore always sets the state for the id */ + /* v8 ignore next -- loadCore always sets the state for the id */ if (!state) throw new Error(`failed to adopt session "${id}"`) return state } @@ -492,7 +475,7 @@ export class PersistenceCoordinator { // resume. const live = await this.backend.loadLive(id, session.header.cwd) if (live !== undefined) { - // Do NOT route through _loadCore(): that crash-repairs open turns as + // Do NOT route through loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) @@ -532,7 +515,7 @@ export class PersistenceCoordinator { owner: session, }) const suffix = seed.slice(events.length) - if (suffix.length > 0) await this._appendCore(session.header.id, suffix) + if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } private async flush(session: Session): Promise { @@ -563,20 +546,9 @@ export class PersistenceCoordinator { /* v8 ignore next -- state is always set by the awaited init before flush */ const cursor = state?.cursor ?? 0 const fresh = batch.filter(e => e.seq >= cursor) - // _appendCore (NOT the serialized append) — drain already runs inside the + // appendCore (NOT the serialized append) — drain already runs inside the // per-session chain, so re-entering via append() would deadlock. - if (fresh.length > 0) await this._appendCore(session.header.id, fresh) + if (fresh.length > 0) await this.appendCore(session.header.id, fresh) buffer.splice(0, batch.length) } - - /** Notify derived read models after source data commits. */ - private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void { - const header = structuredClone(meta) - const snapshot = structuredClone(change) - void Promise.resolve() - .then(() => this.ctx.parallel('session/persisted', header, snapshot)) - .catch((error: unknown) => { - this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`) - }) - } } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index ea98f70e9f..1588ed2526 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -36,29 +36,6 @@ declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence } - - interface Events { - /** - * A persistence backend committed a canonical session-log change. This is - * an observe-only notification for derived read models: the durable write - * has already succeeded, and listener failures are contained rather than - * propagated into append, load, flush, or teardown. - * @param header - snapshotted persisted session metadata. - * @param change - committed seq range and whether it was an append or repair. - * @mode parallel - */ - 'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void - } -} - -/** A committed persisted-log change observed by derived read models. */ -export interface SessionPersistedChange { - /** Whether ordinary append or load-time repair committed the change. */ - kind: 'append' | 'repair' - /** First seq affected by the commit. */ - fromSeq: number - /** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */ - toSeq: number } /** diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 6d95fbb4b2..42583c4fe3 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -31,7 +31,6 @@ import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '../src/index.ts' -import type { SessionPersistedChange } from '../src/index.ts' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -124,40 +123,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('announces committed append and repair ranges without coupling listener failures to writes', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] - ctx.on('session/persisted', (header, change) => { - observed.push({ headerId: header.id, change: structuredClone(change) }) - header.createdAt = -1 - return Promise.reject(new Error('derived read model failed')) - }) - try { - const m = meta('notifications', WORK) - await ctx.sessionPersistence.create(m) - await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - ]) - await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } }) - await Promise.resolve() - await Promise.resolve() - - expect(observed).toEqual([ - { headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } }, - { headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } }, - { headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } }, - ]) - expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the // seed; the boundary must survive a reload (so a resume/replay can tell the @@ -403,10 +368,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) - const repairs: SessionPersistedChange[] = [] - ctx.on('session/persisted', (_header, change) => { - if (change.kind === 'repair') repairs.push(structuredClone(change)) - }) const second = await fix.mount(ctx) // The live session is still the authority: it appends the REAL step/turn // end. Adoption must truncate the torn tail but NOT synthesize closers. @@ -417,7 +378,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - expect(repairs).toEqual([]) await second.dispose() } finally { await ctx.fiber.dispose() @@ -425,34 +385,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('a query-side load preserves the existing live owner binding', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - let session!: Session - const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } }) - send(session, oneTurnLog()) - }, { inject: ['sessions'] })) - try { - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(session.id) - await liveFiber.dispose() - - let replacement!: Session - await ctx.plugin(Object.assign((inner: Context) => { - replacement = inner.sessions.create(session.id, { - seed: loaded.events, - meta: { cwd: WORK, createdAt: loaded.meta.createdAt }, - }) - }, { inject: ['sessions'] })) - await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - // --- collision / id reuse --- it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 15e5b13295..8b0c06a30c 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,9 @@ # session-query/ — session retrieval capability family -Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer. +Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | -The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 47977e6f7a..55f9b32fcd 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,52 +1,23 @@ # @deepseek-ai/dsh-session-query -Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories. +Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. -## Reads and traces +## Reads -- `listSessions()` returns cloned lightweight records in deterministic newest-first order. -- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold. -- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50). -- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content. +- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. +- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. -An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent. +Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. -## Filters - -`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array. - -Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page. - -## Full-text providers - -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Its async disposer removes the provider from selection immediately, lets already accepted transactions finish, and settles after they drain. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. - -The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. - -Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. - -Providers receive resolved `SessionSearchSpec` and `SessionEventSearchSpec` values whose `limit` is required after service defaulting and validation. Public service callers use `SessionSearchRequest` and `SessionEventSearchRequest`, where `limit` remains optional. - -## Errors - -`SessionQueryError.code` is the closed `SessionQueryErrorCode` union: `SESSION_QUERY_ABORTED`, `SESSION_QUERY_DUPLICATE_EXTRACTOR`, `SESSION_QUERY_DUPLICATE_PROVIDER`, `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INDEX_FAILED`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_EXTRACTOR`, `SESSION_QUERY_INVALID_FILTER`, `SESSION_QUERY_INVALID_LIMIT`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_QUERY`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_PROVIDER_AMBIGUOUS`, `SESSION_QUERY_PROVIDER_CONFIGURED_MISSING`, `SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE`, `SESSION_QUERY_PROVIDER_ERROR`, `SESSION_QUERY_PROVIDER_UNAVAILABLE`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. - -## Text extractors - -Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. - -Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor. +`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. ## Configuration | Key | Default | Contract | |---|---:|---| -| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. | -| `defaultLimit` | `20` | Search page size when the request omits `limit`. | -| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. | | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index a3a3a2839a..9f78d4f1db 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query", - "description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)", + "description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index c70eb52988..2736f68cbd 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -1,51 +1,23 @@ -/** - * Public configuration, defaults, and typed failures for session-query. - * - * @module @deepseek-ai/dsh-session-query/config - */ +/** Public configuration and typed failures for session-query. */ import { HarnessError } from '@deepseek-ai/dsh-llm' -/** Default page size for provider-backed search. */ -export const SESSION_QUERY_DEFAULT_LIMIT = 20 -/** Maximum page size accepted by provider-backed search. */ -export const SESSION_QUERY_MAX_LIMIT = 100 /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Configuration for the provider-neutral session-query service. */ +/** Configuration for exact session-query reads. */ export interface Config { - /** Explicit provider id; omitted auto-selects exactly one usable provider. */ - searchProvider?: string - /** Default search result page size. Defaults to 20. */ - defaultLimit?: number - /** Maximum accepted search page size. Defaults to 100. */ - maxLimit?: number /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } -/** Complete stable machine-routable failure taxonomy for session-query. */ +/** Stable machine-routable failure taxonomy for exact session reads. */ export type SessionQueryErrorCode = - | 'SESSION_QUERY_ABORTED' - | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' - | 'SESSION_QUERY_DUPLICATE_PROVIDER' | 'SESSION_QUERY_EVENT_NOT_FOUND' - | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' - | 'SESSION_QUERY_INVALID_EXTRACTOR' - | 'SESSION_QUERY_INVALID_FILTER' - | 'SESSION_QUERY_INVALID_LIMIT' - | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' - | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'SESSION_QUERY_PROVIDER_ERROR' - | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' | 'SESSION_QUERY_SESSION_NOT_FOUND' | 'SESSION_QUERY_SOURCE_CONFLICT' diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 84d6967e01..ebc3d92577 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,45 +1,32 @@ /** Live/persisted logical-corpus resolution for session-query. */ import type { Context } from 'cordis' -import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' -import type { LoadedSession } from './extraction.ts' -import { canonicalJson } from './extraction.ts' import { SessionQueryError } from './config.ts' -interface PersistenceBinding { - token: symbol - service: SessionPersistence - headers: Map - /** Notifications retained until a list that began after them completes. */ - observations: Map - observationGeneration: number - error?: unknown - refreshing: Promise | undefined -} - -interface PersistedObservation { - generation: number +/** Detached source selected for one exact read. */ +export interface LogicalSession { + /** Cloned source header. */ header: SessionHeader + /** Cloned raw event log. */ + events: SessionEvent[] } -/** Active persistence view used by provider reconciliation. */ -export interface PersistenceView { - /** Canonical headers in deterministic creation order. */ - headers: SessionHeader[] - /** Load one canonical persisted source. */ - load(id: SessionId): Promise -} - -/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */ +/** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { - private _persistence: PersistenceBinding | undefined + private _persistence: SessionPersistence | undefined constructor(private readonly _ctx: Context) { _ctx.effect(() => { const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { - this._attachPersistence(childCtx, childCtx.sessionPersistence) + const service = childCtx.sessionPersistence + this._persistence = service + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistence === service) this._persistence = undefined + }, 'sessionQuery.persistenceBinding') }) return () => void fiber.dispose() }, 'sessionQuery.optionalPersistence') @@ -47,23 +34,22 @@ export class SessionCorpus { /** * List the complete logical corpus with live precedence and cloned headers. - * @returns logical records in deterministic newest-first order. + * @returns records in deterministic newest-first order. */ async listSessions(): Promise { - const binding = await this._ensurePersistence() + const persistence = this._persistence + const persisted = persistence === undefined ? [] : await listPersisted(persistence) const records = new Map() - if (binding !== undefined) { - for (const header of binding.headers.values()) { - records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) - } + for (const header of persisted) { + records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) } for (const session of this._ctx.sessions.list()) { - const persisted = binding?.headers.get(session.id) - if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted) + const durable = records.get(session.id) + if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header) records.set(session.id, { header: structuredClone(session.header), live: true, - persisted: persisted !== undefined, + persisted: durable !== undefined, }) } return [...records.values()].sort(compareSessions) @@ -71,156 +57,69 @@ export class SessionCorpus { /** * Load one logical source, preferring a detached live snapshot. + * + * A known live target never consults persistence, so an optional backend's + * failure cannot make current in-memory history unreadable. * @param sessionId - session to resolve. - * @returns detached live-preferred metadata and events. + * @returns detached live-preferred header and events. */ - async loadLogical(sessionId: SessionId): Promise { + async load(sessionId: SessionId): Promise { const live = this._ctx.sessions.get(sessionId) - if (live !== undefined) return this.snapshotLive(live) - const binding = await this._ensurePersistence() - if (binding === undefined || !binding.headers.has(sessionId)) { - throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - return this._loadPersisted(binding, sessionId) - } - - /** - * Return a detached live source with current availability flags. - * @param session - live session to snapshot. - * @returns detached metadata and events. - */ - snapshotLive(session: Session): LoadedSession { - const persistedHeader = this._persistence?.headers.get(session.id) - if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader) - return { - record: { - header: structuredClone(session.header), - live: true, - persisted: persistedHeader !== undefined, - }, - events: session.events.map(event => structuredClone(event)), - } - } - - /** - * Get one live session without consulting persistence. - * @param sessionId - live id to resolve. - * @returns current store object, or undefined. - */ - getLive(sessionId: SessionId): Session | undefined { - return this._ctx.sessions.get(sessionId) - } - - /** - * List live sessions in store order. - * @returns fresh array of current store objects. - */ - listLive(): Session[] { - return this._ctx.sessions.list() - } - - /** - * Resolve an authoritative persisted view. - * @returns cloned headers and loader, or undefined while unmounted. - */ - async persistenceView(): Promise { - const binding = await this._ensurePersistence() - if (binding === undefined) return undefined - return { - headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending), - load: id => this._loadPersisted(binding, id), - } - } - - private _attachPersistence(ctx: Context, service: SessionPersistence): void { - const binding: PersistenceBinding = { - token: Symbol('session-query-persistence'), - service, - headers: new Map(), - observations: new Map(), - observationGeneration: 0, - refreshing: undefined, - } - this._persistence = binding - void this._refreshPersistence(binding) - ctx.on('session/persisted', (header) => { - /* v8 ignore next -- a stale notification can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - const snapshot = structuredClone(header) - const observation = { generation: ++binding.observationGeneration, header: snapshot } - binding.headers.set(header.id, snapshot) - binding.observations.set(header.id, observation) - }) - ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') - } - - private _detachPersistence(binding: PersistenceBinding): void { - /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ - if (this._persistence?.token !== binding.token) return - this._persistence = undefined - } - - private _refreshPersistence(binding: PersistenceBinding): Promise { - if (binding.refreshing !== undefined) return binding.refreshing - const startGeneration = binding.observationGeneration - const refresh = binding.service.list().then((headers) => { - /* v8 ignore next -- a list completion can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)])) - for (const [id, observation] of binding.observations) { - // A notification newer than this list's snapshot is the authoritative - // read-your-writes layer; older ones must already be present in list(). - if (observation.generation > startGeneration) { - nextHeaders.set(id, structuredClone(observation.header)) - } else { - binding.observations.delete(id) - } - } - binding.headers = nextHeaders - binding.error = undefined - }).catch((error: unknown) => { - /* v8 ignore next -- a failed list can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - binding.error = error - }).finally(() => { - /* v8 ignore next -- a newer refresh may already own the slot */ - if (binding.refreshing === refresh) binding.refreshing = undefined - }) - binding.refreshing = refresh - return refresh - } - - private async _ensurePersistence(): Promise { - const binding = this._persistence - if (binding === undefined) return undefined - await this._refreshPersistence(binding) - if (binding.error !== undefined) { - const cause = binding.error - throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause }) - } - return binding - } - - private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise { + if (live !== undefined) return snapshotLive(live) + const persistence = this._persistence + if (persistence === undefined) throw notFound(sessionId) + const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) + if (listed === undefined) throw notFound(sessionId) + let loaded: Awaited> try { - const loaded = await binding.service.load(sessionId) - const listed = binding.headers.get(sessionId) - /* v8 ignore else -- every internal persisted load starts from a listed header */ - if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed) - return { - record: { header: structuredClone(loaded.meta), live: false, persisted: true }, - events: loaded.events.map(event => structuredClone(event)), - } + loaded = await persistence.load(sessionId) } catch (error: unknown) { - if (error instanceof SessionQueryError) throw error - throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }) + throw new SessionQueryError( + `failed to load session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } + assertCompatibleHeaders(loaded.meta, listed) + return { + header: structuredClone(loaded.meta), + events: loaded.events.map(event => structuredClone(event)), } } +} - private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { - if (canonicalJson(a) !== canonicalJson(b)) { - throw new SessionQueryError(`live and persisted headers conflict for session "${a.id}"`, 'SESSION_QUERY_SOURCE_CONFLICT') - } +async function listPersisted(persistence: SessionPersistence): Promise { + try { + return await persistence.list() + } catch (error: unknown) { + throw new SessionQueryError( + `session persistence listing failed: ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } +} + +function snapshotLive(session: Session): LogicalSession { + return { + header: structuredClone(session.header), + events: session.events.map(event => structuredClone(event)), + } +} + +function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { + if ( + a.version !== b.version + || a.id !== b.id + || a.createdAt !== b.createdAt + || a.cwd !== b.cwd + || a.parentSession !== b.parentSession + || a.seedLength !== b.seedLength + ) { + throw new SessionQueryError( + `live and persisted headers conflict for session "${a.id}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) } } @@ -228,11 +127,10 @@ function compareSessions(a: SessionRecord, b: SessionRecord): number { return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) } -function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number { - return a.createdAt - b.createdAt || a.id.localeCompare(b.id) +function notFound(sessionId: SessionId): SessionQueryError { + return new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') } function errorMessage(error: unknown): string { - /* v8 ignore next -- persistence service contracts reject Error instances */ return error instanceof Error ? error.message : 'unknown error' } diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts deleted file mode 100644 index c4ad294a4f..0000000000 --- a/packages/session-query/session-query/src/extraction.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** Semantic text extraction and stable provider snapshot fingerprints. */ - -import { createHash } from 'node:crypto' -import type { Context } from 'cordis' -import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session' -import type { - SessionContentTextExtractor, - SessionEventTextExtractor, - SessionIndexDocument, - SessionIndexSnapshot, - SessionRecord, -} from './types.ts' -import { SessionQueryError } from './config.ts' -import { eventRecords } from './tracing.ts' - -/** Canonical session source consumed by extraction and provider reconciliation. */ -export interface LoadedSession { - /** Logical source metadata. */ - record: SessionRecord - /** Detached canonical events. */ - events: SessionEvent[] -} - -interface StoredEventExtractor { - version: string - extract(event: SessionEvent): readonly string[] -} - -interface StoredContentExtractor { - version: string - extract(block: ContentBlock): readonly string[] -} - -/** Owns core/custom semantic extractors and builds versioned index snapshots. */ -export class SessionTextExtractors { - private readonly _eventExtractors = new Map() - private readonly _contentExtractors = new Map() - - constructor() { - this._installCoreExtractors() - } - - /** - * Register one effect-scoped event extractor. - * @param ctx - contributing caller context. - * @param type - event discriminant. - * @param extractor - versioned semantic extractor. - * @returns disposer for the registration. - */ - registerEvent( - ctx: Context, - type: K, - extractor: SessionEventTextExtractor, - ): () => void { - this._validateVersion(type, extractor.version) - if (this._eventExtractors.has(type)) { - throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') - } - const stored: StoredEventExtractor = { - version: extractor.version, - extract: event => extractor.extract(event as SessionEvent), - } - const dispose = ctx.effect(function* (this: SessionTextExtractors) { - this._eventExtractors.set(type, stored) - yield () => { - this._eventExtractors.delete(type) - } - }.bind(this), `sessionQuery.eventExtractor(${type})`) - return () => void dispose() - } - - /** - * Register one effect-scoped content-block extractor. - * @param ctx - contributing caller context. - * @param type - content-block discriminant. - * @param extractor - versioned semantic extractor. - * @returns disposer for the registration. - */ - registerContent( - ctx: Context, - type: K, - extractor: SessionContentTextExtractor, - ): () => void { - this._validateVersion(type, extractor.version) - if (this._contentExtractors.has(type)) { - throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') - } - const stored: StoredContentExtractor = { - version: extractor.version, - extract: block => extractor.extract(block as ContentBlockMap[K]), - } - const dispose = ctx.effect(function* (this: SessionTextExtractors) { - this._contentExtractors.set(type, stored) - yield () => { - this._contentExtractors.delete(type) - } - }.bind(this), `sessionQuery.contentExtractor(${type})`) - return () => void dispose() - } - - /** - * Build one provider-neutral snapshot and SHA-256 source/version fingerprint. - * @param loaded - detached canonical source. - * @returns lightweight documents and stable fingerprint. - */ - buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot { - const records = eventRecords(loaded.record.header.id, loaded.events) - const documents: SessionIndexDocument[] = [] - const eventVersions = new Set() - const blockVersions = new Set() - for (const event of loaded.events) { - const extractor = this._eventExtractors.get(event.type) - if (extractor === undefined) continue - eventVersions.add(`${event.type}@${extractor.version}`) - collectBlockVersions(event.data, this._contentExtractors, blockVersions) - const text = normalizeText(extractor.extract(event)) - if (text.length === 0) continue - // The event record array parallels the contiguous log. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - documents.push({ ...records[event.seq]!, text }) - } - const fingerprint = createHash('sha256').update(canonicalJson({ - header: loaded.record.header, - events: loaded.events, - eventExtractors: [...eventVersions].sort(), - contentExtractors: [...blockVersions].sort(), - })).digest('hex') - return { - session: cloneRecord(loaded.record), - fingerprint, - documents, - } - } - - private _installCoreExtractors(): void { - this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] }) - this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] }) - this._contentExtractors.set('tool-call', { - version: '1', - extract: (block) => { - const call = block as ContentBlockMap['tool-call'] - return [call.name, call.arguments] - }, - }) - this._contentExtractors.set('tool-result', { - version: '1', - extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content), - }) - for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) { - this._eventExtractors.set(type, { - version: '1', - extract: event => this._extractBlocks((event as SessionEvent).data.content), - }) - } - this._eventExtractors.set('prompt/blocked', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'prompt/blocked'>).data - return [...this._extractBlocks(data.content), data.reason] - }, - }) - this._eventExtractors.set('tool/call', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'tool/call'>).data - return [data.name, data.arguments] - }, - }) - this._eventExtractors.set('tool/result', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'tool/result'>).data - return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? ''] - }, - }) - this._eventExtractors.set('todo/write', { - version: '1', - extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`), - }) - this._eventExtractors.set('turn/end', { - version: '1', - extract: (event) => { - const reason = (event as SessionEvent<'turn/end'>).data.reason - switch (reason.kind) { - case 'error': return ['error', reason.message, reason.code ?? ''] - case 'aborted': return ['aborted', reason.reason ?? ''] - case 'rejected': return ['rejected', reason.reason] - case 'disposed': return ['disposed'] - case 'max-tokens': return ['max-tokens'] - case 'interrupted': return ['interrupted'] - case 'completed': return [] - // TurnEndReasonMap is merge-extensible; unknown variants contribute no text. - /* v8 ignore next -- only an external declaration-merged reason can reach this fallback */ - default: return [] - } - }, - }) - } - - private _extractBlocks(blocks: readonly ContentBlock[]): string[] { - const fragments: string[] = [] - for (const block of blocks) { - const extractor = this._contentExtractors.get(block.type) - if (extractor !== undefined) fragments.push(...extractor.extract(block)) - } - return fragments - } - - private _validateVersion(type: string, version: string): void { - if (version.trim().length === 0) { - throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR') - } - } -} - -/** - * Encode canonical JSON with recursively sorted object keys. - * @param value - JSON-compatible source value. - * @returns deterministic JSON text. - */ -export function canonicalJson(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` - const object = value as Record - return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}` -} - -function normalizeText(fragments: readonly string[]): string { - return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n') -} - -function collectBlockVersions( - value: unknown, - extractors: ReadonlyMap, - versions: Set, -): void { - if (Array.isArray(value)) { - for (const item of value) collectBlockVersions(item, extractors, versions) - return - } - if (value === null || typeof value !== 'object') return - const object = value as Record - if (typeof object.type === 'string') { - const type = object.type as ContentBlockType - const extractor = extractors.get(type) - if (extractor !== undefined) versions.add(`${type}@${extractor.version}`) - } - for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions) -} - -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } -} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts deleted file mode 100644 index 296361cfbf..0000000000 --- a/packages/session-query/session-query/src/filters.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** Pure serializable session-query result filters. */ - -import { assertNever } from '@deepseek-ai/dsh-llm' -import type { - SessionEventRecord, - SessionEventResultFilter, - SessionQueryRange, - SessionRecord, - SessionResultFilter, -} from './types.ts' -import { SessionQueryError } from './config.ts' - -const AVAILABILITIES = ['live', 'persisted'] as const -const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const - -/** - * Apply an ordered AND-chain of session filters while preserving item order - * and the concrete generic item type. - * @param results - session records or richer session search hits. - * @param filters - serializable filters applied in order. - * @returns a fresh filtered array. - */ -export function filterSessionResults( - results: readonly T[], - filters: readonly SessionResultFilter[], -): T[] { - for (const filter of filters) validateSessionFilter(filter) - return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter))) -} - -/** - * Apply an ordered AND-chain of event filters while preserving item order and - * the concrete generic item type. - * @param results - event records or richer event search hits. - * @param filters - serializable filters applied in order. - * @returns a fresh filtered array. - */ -export function filterEventResults( - results: readonly T[], - filters: readonly SessionEventResultFilter[], -): T[] { - for (const filter of filters) validateEventFilter(filter) - return results.filter(result => filters.every(filter => matchesEventFilter(result, filter))) -} - -function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean { - switch (filter.kind) { - case 'id': return filter.values.includes(record.header.id) - case 'cwd': return filter.values.includes(record.header.cwd ?? null) - case 'created-at': return inRange(record.header.createdAt, filter.range) - case 'parent': return filter.values.includes(record.header.parentSession ?? null) - case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted) - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: return assertNever(filter) - } -} - -function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean { - switch (filter.kind) { - case 'seq': return inRange(record.seq, filter.range) - case 'time': return inRange(record.time, filter.range) - case 'type': return filter.values.includes(record.type) - case 'surface': return filter.values.includes(record.surface) - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: return assertNever(filter) - } -} - -function validateSessionFilter(filter: SessionResultFilter): void { - switch (filter.kind) { - case 'id': - case 'cwd': - case 'parent': - return - case 'created-at': - validateRange('created-at', filter.range) - return - case 'availability': - for (const value of filter.values) { - if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`) - } - return - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: - assertNever(filter) - } -} - -function validateEventFilter(filter: SessionEventResultFilter): void { - switch (filter.kind) { - case 'seq': - case 'time': - validateRange(filter.kind, filter.range) - return - case 'type': - return - case 'surface': - for (const value of filter.values) { - if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`) - } - return - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: - assertNever(filter) - } -} - -function validateRange(name: string, range: SessionQueryRange): void { - if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`) - if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - invalidFilter(`${name}.from must be <= ${name}.to`) - } -} - -function invalidFilter(message: string): never { - throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER') -} - -function inRange(value: number, range: SessionQueryRange): boolean { - return (range.from === undefined || value >= range.from) - && (range.to === undefined || value <= range.to) -} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index c07c0b8d05..828fe2ec88 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -1,53 +1,29 @@ /** - * Provider-neutral session-history retrieval over live and optionally - * persisted session logs. The public service composes logical-corpus reads, - * pure filters and tracing, semantic extraction, and provider coordination. + * Exact session-history reads over live and optionally persisted logs. * * @module @deepseek-ai/dsh-session-query */ import { Context, Service } from 'cordis' import z from 'schemastery' -import type { ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session' +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { - SessionContentTextExtractor, SessionEventReadRequest, SessionEventRecord, - SessionEventSearchHit, - SessionEventSearchRequest, - SessionEventTextExtractor, - SessionEventTrace, SessionEventWindow, - SessionLineageTrace, SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchRequest, - SessionQueryExecContext, } from './types.ts' import { - SESSION_QUERY_DEFAULT_LIMIT, - SESSION_QUERY_MAX_LIMIT, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, type Config, } from './config.ts' -import { SessionTextExtractors } from './extraction.ts' import { SessionCorpus } from './corpus.ts' -import { SessionProviderCoordinator } from './provider.ts' -import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' -export { - SESSION_QUERY_DEFAULT_LIMIT, - SESSION_QUERY_MAX_LIMIT, - SESSION_QUERY_READ_WINDOW_MAX, - SessionQueryError, -} from './config.ts' -export { filterEventResults, filterSessionResults } from './filters.ts' +export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' declare module 'cordis' { interface Context { @@ -55,35 +31,25 @@ declare module 'cordis' { } } -/** Session-history retrieval and provider coordination service. */ +/** Live-preferred logical-corpus and exact-event read service. */ export class SessionQueryService extends Service { static inject = ['sessions'] static Config: z = z.object({ - searchProvider: z.string(), - defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT), - maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT), readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), }) private readonly _readWindowMax: number - private readonly _extractors: SessionTextExtractors - private readonly _providers: SessionProviderCoordinator private readonly _corpus: SessionCorpus constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionQuery') - const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT - const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX - if (defaultLimit > maxLimit) { - throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') + if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) { + throw new SessionQueryError( + 'session-query: readWindowMax must be a non-negative integer', + 'SESSION_QUERY_INVALID_CONFIG', + ) } - this._extractors = new SessionTextExtractors() - this._providers = new SessionProviderCoordinator({ - ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, - defaultLimit, - maxLimit, - }, () => this._corpus, this._extractors) this._corpus = new SessionCorpus(ctx) } @@ -101,7 +67,7 @@ export class SessionQueryService extends Service { * @returns event records in ascending seq order. */ async listEvents(sessionId: SessionId): Promise { - const loaded = await this._corpus.loadLogical(sessionId) + const loaded = await this._corpus.load(sessionId) return eventRecords(sessionId, loaded.events) } @@ -113,113 +79,58 @@ export class SessionQueryService extends Service { async readEvent(request: SessionEventReadRequest): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) - const loaded = await this._corpus.loadLogical(request.sessionId) + const loaded = await this._corpus.load(request.sessionId) const target = loaded.events[request.seq] if (target === undefined || target.seq !== request.seq) { - throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + throw new SessionQueryError( + `session "${request.sessionId}" has no event at seq ${request.seq}`, + 'SESSION_QUERY_EVENT_NOT_FOUND', + ) } const startSeq = Math.max(0, request.seq - before) const endSeq = Math.min(loaded.events.length - 1, request.seq + after) return { - session: cloneRecord(loaded.record), - target: structuredClone(target), - events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)), + session: loaded.header, + target, + events: loaded.events.slice(startSeq, endSeq + 1), startSeq, endSeq, } } - /** - * Trace parent ancestry and the complete known descendant tree of a session. - * @param sessionId - logical session id to trace. - * @returns complete or explicitly partial lineage. - */ - async traceSession(sessionId: SessionId): Promise { - return traceLineage(await this._corpus.listSessions(), sessionId) - } - - /** - * Trace direct provenance and surface replacement relationships for any event. - * @param sessionId - logical session containing the target. - * @param seq - target event seq. - * @returns lightweight trace with related seq links. - */ - async traceEvent(sessionId: SessionId, seq: number): Promise { - return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq) - } - - /** - * Register one full-text provider with effect-scoped disposal. - * @param provider - provider and synchronization implementation. - * @returns async disposer that immediately unregisters selection and awaits accepted provider work. - */ - registerSearchProvider(provider: SessionSearchProvider): () => Promise { - return this._providers.register(this.ctx, provider) - } - - /** - * Register semantic text extraction for one event type. - * @param type - declaration-merged event discriminant. - * @param extractor - stable version and typed extraction callback. - * @returns disposer that removes the extractor. - */ - registerEventTextExtractor( - type: K, - extractor: SessionEventTextExtractor, - ): () => void { - return this._extractors.registerEvent(this.ctx, type, extractor) - } - - /** - * Register semantic text extraction for one content block type. - * @param type - declaration-merged content-block discriminant. - * @param extractor - stable version and typed extraction callback. - * @returns disposer that removes the extractor. - */ - registerContentTextExtractor( - type: K, - extractor: SessionContentTextExtractor, - ): () => void { - return this._extractors.registerContent(this.ctx, type, extractor) - } - - /** - * Search the complete logical corpus and rank one result per session. - * @param request - query, pre-ranking filters, and pagination. - * @param exec - optional cancellation context. - * @returns ranked provider page. - */ - searchSessions( - request: SessionSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - return this._providers.searchSessions(request, exec) - } - - /** - * Search events within one logical session. - * @param request - target session, query, filters, and pagination. - * @param exec - optional cancellation context. - * @returns ranked provider page. - */ - searchEvents( - request: SessionEventSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - return this._providers.searchEvents(request, exec) - } - private _readWindow(name: 'before' | 'after', value: number | undefined): number { if (value === undefined) return 0 if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) { - throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW') + throw new SessionQueryError( + `${name} must be an integer between 0 and ${this._readWindowMax}`, + 'SESSION_QUERY_INVALID_WINDOW', + ) } return value } } -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } +function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } + const current = new Set(folded.nodes.map(node => node.seq)) + const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + })) } export default SessionQueryService diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts deleted file mode 100644 index e4235c6d8a..0000000000 --- a/packages/session-query/session-query/src/provider.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** Search-provider selection, synchronization, pagination, and cancellation. */ - -import type { Context } from 'cordis' -import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionTextExtractors } from './extraction.ts' -import type { PersistenceView, SessionCorpus } from './corpus.ts' -import type { - SessionEventRecord, - SessionEventSearchHit, - SessionEventSearchRequest, - SessionEventSearchSpec, - SessionQueryExecContext, - SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchRequest, - SessionSearchSpec, -} from './types.ts' -import type { Config } from './config.ts' -import { SessionQueryError } from './config.ts' -import { filterEventResults, filterSessionResults } from './filters.ts' - -interface ProviderState { - provider: SessionSearchProvider - chain: Promise - liveIds: Set -} - -/** Coordinates one selected provider against live and persisted corpus layers. */ -export class SessionProviderCoordinator { - private readonly _configuredProviderId: string | undefined - private readonly _defaultLimit: number - private readonly _maxLimit: number - private readonly _providers = new Map() - - constructor( - config: Required> & Pick, - private readonly _corpus: () => SessionCorpus, - private readonly _extractors: SessionTextExtractors, - ) { - this._configuredProviderId = config.searchProvider - this._defaultLimit = config.defaultLimit - this._maxLimit = config.maxLimit - } - - /** - * Register one effect-scoped provider. - * @param ctx - contributing caller context. - * @param provider - provider implementation. - * @returns async disposer that deselects immediately and drains accepted work. - */ - register(ctx: Context, provider: SessionSearchProvider): () => Promise { - if (this._providers.has(provider.id)) { - throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') - } - const state: ProviderState = { - provider, - chain: Promise.resolve(), - liveIds: new Set(), - } - const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { - this._providers.set(provider.id, state) - yield async () => { - this._providers.delete(provider.id) - await state.chain - } - }.bind(this), 'sessionQuery.registerSearchProvider()') - return async () => { await dispose() } - } - - /** - * Search and group the complete logical corpus. - * @param request - normalized provider-neutral request input. - * @param exec - optional cancellation controls. - * @returns ranked session page. - */ - async searchSessions( - request: SessionSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - const state = this._resolveProvider() - const normalized = this._normalizeSessionSearch(request) - const work = this._runFullSearch(state, undefined, async () => { - if (exec?.signal?.aborted) throw aborted() - const result = await state.provider.searchSessions(normalized, exec) - return this._validateSearchPage(state, result, normalized.limit) - }) - return waitFor(work, exec?.signal) - } - - /** - * Search events within one logical session. - * @param request - target and provider-neutral request input. - * @param exec - optional cancellation controls. - * @returns ranked event page. - */ - async searchEvents( - request: SessionEventSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - const state = this._resolveProvider() - const normalized = this._normalizeEventSearch(request) - const query = async (): Promise> => { - if (exec?.signal?.aborted) throw aborted() - const result = await state.provider.searchEvents(normalized, exec) - return this._validateSearchPage(state, result, normalized.limit) - } - const live = this._corpus().getLive(request.sessionId) - let work: Promise> - if (live !== undefined) { - work = this._runLiveSearch(state, live, query) - } else { - work = this._runFullSearch(state, request.sessionId, query) - } - return waitFor(work, exec?.signal) - } - - private _runFullSearch( - state: ProviderState, - requiredSessionId: SessionId | undefined, - query: () => Promise, - ): Promise { - const liveSessions = this._corpus().listLive() - return this._serialize(state, async () => { - await this._synchronize(state, async () => { - const persistence = await this._corpus().persistenceView() - const missingRequired = requiredSessionId !== undefined - && (persistence === undefined || !persistence.headers.some(header => header.id === requiredSessionId)) - if (missingRequired) { - throw new SessionQueryError(`session "${requiredSessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - if (persistence === undefined) { - await state.provider.setPersistedActive(false) - } else { - await this._syncPersisted(state, persistence) - } - await this._replaceLiveCorpus(state, liveSessions) - }) - return query() - }) - } - - private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { - await state.provider.setPersistedActive(false) - const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint])) - for (const header of persistence.headers) { - const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id)) - if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot) - inventory.delete(header.id) - } - for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId) - await state.provider.setPersistedActive(true) - } - - private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise { - const liveIds = new Set(sessions.map(session => session.id)) - for (const staleId of state.liveIds) { - if (!liveIds.has(staleId)) await state.provider.removeLive(staleId) - } - for (const session of sessions) { - await state.provider.replaceLive(this._snapshotLive(session)) - } - state.liveIds = liveIds - } - - private _runLiveSearch(state: ProviderState, session: Session, query: () => Promise): Promise { - let snapshot: ReturnType - try { - snapshot = this._snapshotLive(session) - } catch (error: unknown) { - return Promise.reject(this._synchronizationError(state, error)) - } - return this._serialize(state, async () => { - await this._synchronize(state, async () => { - await state.provider.replaceLive(snapshot) - state.liveIds.add(session.id) - }) - return query() - }) - } - - private _snapshotLive(session: Session): ReturnType { - return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) - } - - /** Serialize reconciliation and its provider query as one stable transaction. */ - private _serialize(state: ProviderState, operation: () => Promise): Promise { - const next = state.chain.then(operation, operation) - state.chain = next.then(() => undefined, () => undefined) - return next - } - - /** Translate only derived-index update failures, never provider query failures. */ - private async _synchronize(state: ProviderState, operation: () => Promise): Promise { - try { - await operation() - } catch (error: unknown) { - throw this._synchronizationError(state, error) - } - } - - private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { - /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ - if (error instanceof SessionQueryError) return error - return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) - } - - private _resolveProvider(): ProviderState { - if (this._configuredProviderId !== undefined) { - const state = this._providers.get(this._configuredProviderId) - if (state === undefined) { - throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING') - } - if (!state.provider.status().available) { - throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE') - } - return state - } - const usable = [...this._providers.values()].filter(state => state.provider.status().available) - const [single] = usable - if (single === undefined) { - throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE') - } - if (usable.length > 1) { - throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS') - } - return single - } - - private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec { - const query = this._queryText(request.query) - const limit = this._limitValue(request.limit) - filterSessionResults([], request.sessionFilters ?? []) - filterEventResults([], request.eventFilters ?? []) - return { ...request, query, limit } - } - - private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec { - const query = this._queryText(request.query) - const limit = this._limitValue(request.limit) - filterEventResults([], request.filters ?? []) - return { ...request, query, limit } - } - - private _queryText(query: string): string { - const normalized = query.trim() - if (normalized.length === 0) { - throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY') - } - return normalized - } - - private _limitValue(limit: number | undefined): number { - const value = limit ?? this._defaultLimit - if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) { - throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT') - } - return value - } - - private _validateSearchPage(state: ProviderState, page: SessionSearchPage, limit: number): SessionSearchPage { - if (page.providerId !== state.provider.id) { - throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') - } - if (page.items.length > limit) { - throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR') - } - return page - } -} - -function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { - const observed = work.catch((error: unknown) => { throw operationError(error) }) - if (signal === undefined) return observed - if (signal.aborted) { - // Cancellation supersedes the caller's result, but shared work must still - // have a rejection observer when it has already failed synchronously. - void observed.catch((_supersededError: unknown) => undefined) - return Promise.reject(aborted()) - } - return new Promise((resolve, reject) => { - const onAbort = () => { reject(aborted()) } - signal.addEventListener('abort', onAbort, { once: true }) - observed.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener('abort', onAbort) - reject(operationError(error)) - }, - ) - }) -} - -function operationError(error: unknown): Error { - if (error instanceof Error) return error - return new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }) -} - -function aborted(): SessionQueryError { - return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') -} - -function errorMessage(error: unknown): string { - /* v8 ignore next -- provider update contracts reject Error instances */ - return error instanceof Error ? error.message : 'unknown error' -} diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts deleted file mode 100644 index 61363e8254..0000000000 --- a/packages/session-query/session-query/src/tracing.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** Session lineage and event surface/provenance tracing. */ - -import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { - SessionEventRecord, - SessionEventTrace, - SessionLineageNode, - SessionLineageTrace, - SessionRecord, -} from './types.ts' -import { SessionQueryError } from './config.ts' - -/** - * Classify raw events against the canonical surface fold. - * @param sessionId - owner of the event log. - * @param events - detached raw log. - * @returns lightweight records in seq order. - */ -export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - const fold = safeFold(events) - const current = new Set(fold.nodes.map(node => node.seq)) - const shadowed = new Set(fold.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - -/** - * Build one event trace from a validated logical event log. - * @param sessionId - owner of the event log. - * @param events - detached raw log. - * @param seq - target event seq. - * @returns direct provenance and replacement relationships. - */ -export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace { - const target = events[seq] - if (target === undefined || target.seq !== seq) { - throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') - } - const records = eventRecords(sessionId, events) - const fold = safeFold(events) - const shadowedBy = new Map() - const shadows = new Map() - for (const replacement of fold.replacements) { - shadows.set(replacement.seq, [...replacement.shadowedSeqs]) - for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq) - } - const references: number[] = [] - const referencedBy: number[] = [] - for (const event of events) { - if (!isSurfaceEvent(event)) continue - for (const source of event.sourceEventSeqs ?? []) { - if (event.seq === seq) references.push(source) - if (source === seq) referencedBy.push(event.seq) - } - } - const replacementChain: number[] = [] - let replacement = shadowedBy.get(seq) - while (replacement !== undefined) { - replacementChain.push(replacement) - replacement = shadowedBy.get(replacement) - } - const immediate = shadowedBy.get(seq) - // seq was checked against the contiguous event log, so its parallel record exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const targetRecord = records[seq]! - return { - target: { ...targetRecord }, - ...immediate !== undefined ? { shadowedBy: immediate } : {}, - replacementChain, - shadows: shadows.get(seq) ?? [], - references, - referencedBy, - } -} - -/** - * Trace ancestry and descendants within one materialized logical corpus. - * @param records - complete visible logical corpus. - * @param sessionId - target session id. - * @returns complete known lineage or explicit unresolved parent. - */ -export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace { - const byId = new Map(records.map(record => [record.header.id, record])) - const target = byId.get(sessionId) - if (target === undefined) { - throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - - const parents: SessionRecord[] = [] - const ancestrySeen = new Set([sessionId]) - let unresolvedParentId: SessionId | undefined - let parentId = target.header.parentSession - while (parentId !== undefined) { - if (ancestrySeen.has(parentId)) lineageCycle(parentId) - ancestrySeen.add(parentId) - const parent = byId.get(parentId) - if (parent === undefined) { - unresolvedParentId = parentId - break - } - parents.push(parent) - parentId = parent.header.parentSession - } - - const childrenByParent = new Map() - for (const record of records) { - const parent = record.header.parentSession - if (parent === undefined) continue - const children = childrenByParent.get(parent) ?? [] - children.push(record) - childrenByParent.set(parent, children) - } - for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) - const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({ - session: cloneRecord(child), - children: buildChildren(child.header.id), - })) - - return { - target: cloneRecord(target), - parents: parents.map(cloneRecord), - ...unresolvedParentId !== undefined - ? { unresolvedParentId } - : { root: cloneRecord(parents.at(-1) ?? target) }, - children: buildChildren(sessionId), - } -} - -function safeFold(events: readonly SessionEvent[]): ReturnType { - try { - return foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error }) - } -} - -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } -} - -function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { - return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) -} - -function lineageCycle(id: SessionId): never { - throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE') -} - -function errorMessage(error: unknown): string { - /* v8 ignore next -- foldSurface throws Error instances */ - return error instanceof Error ? error.message : 'unknown error' -} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 11e6f6d0fc..5c49695dda 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -1,31 +1,24 @@ /** - * Public vocabulary for the session-query retrieval service: lightweight - * records, composable filters, traces, search requests/results, extractor - * registrations, and the provider synchronization contract. + * Public records for exact reads over the live-preferred logical session corpus. * * @module @deepseek-ai/dsh-session-query/types */ -import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { - SessionEvent, - SessionEventType, - SessionHeader, - SessionId, -} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -/** Whether an event is on the current surface, was replaced, or is log-only. */ +/** Whether an event is current model context, replaced context, or raw-log-only. */ export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' -/** Lightweight identity and availability for one logical session. */ +/** Lightweight identity and source availability for one logical session. */ export interface SessionRecord { - /** Cloned immutable session header selected from the live-preferred corpus. */ + /** Cloned session header selected from the live-preferred corpus. */ header: SessionHeader /** Whether the id currently exists in `ctx.sessions`. */ live: boolean /** Whether the active persistence backend currently materializes the id. */ persisted: boolean } + /** Lightweight metadata for one event within a logical session. */ export interface SessionEventRecord { /** Session that owns the event. */ @@ -40,102 +33,6 @@ export interface SessionEventRecord { surface: SessionEventSurface } -/** Inclusive numeric range used by result and search filters. */ -export interface SessionQueryRange { - /** Inclusive lower bound. */ - from?: number - /** Inclusive upper bound. */ - to?: number -} - -/** Serializable filter applied to session records. */ -export type SessionResultFilter = - | { kind: 'id'; values: readonly SessionId[] } - | { kind: 'cwd'; values: readonly (string | null)[] } - | { kind: 'created-at'; range: SessionQueryRange } - | { kind: 'parent'; values: readonly (SessionId | null)[] } - | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } - -/** Serializable filter applied to event records. */ -export type SessionEventResultFilter = - | { kind: 'seq'; range: SessionQueryRange } - | { kind: 'time'; range: SessionQueryRange } - | { kind: 'type'; values: readonly SessionEventType[] } - | { kind: 'surface'; values: readonly SessionEventSurface[] } - -/** Caller cancellation threaded through synchronization and provider search. */ -export interface SessionQueryExecContext { - /** Abort signal for waiting and provider-owned query work. */ - readonly signal?: AbortSignal -} - -/** Cheap local usability status returned by a search provider. */ -export type SessionSearchProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } - -/** Common pagination fields accepted by both search scopes. */ -export interface SessionSearchPageRequest { - /** Maximum number of hits on this page. */ - limit?: number - /** Opaque cursor returned by the same provider/request. */ - cursor?: string -} - -/** Cross-session full-text request. */ -export interface SessionSearchRequest extends SessionSearchPageRequest { - /** Plain text query interpreted by the selected provider. */ - query: string - /** Session metadata filters applied before event ranking/grouping. */ - sessionFilters?: readonly SessionResultFilter[] - /** Event metadata filters applied before best-event grouping. */ - eventFilters?: readonly SessionEventResultFilter[] -} - -/** Full-text request scoped to one session's events. */ -export interface SessionEventSearchRequest extends SessionSearchPageRequest { - /** Session whose events form the search corpus. */ - sessionId: SessionId - /** Plain text query interpreted by the selected provider. */ - query: string - /** Event metadata filters applied before ranking. */ - filters?: readonly SessionEventResultFilter[] -} - -/** Provider-facing cross-session search spec after service normalization. */ -export interface SessionSearchSpec extends SessionSearchRequest { - /** Required page size validated and defaulted by the query service. */ - limit: number -} - -/** Provider-facing event search spec after service normalization. */ -export interface SessionEventSearchSpec extends SessionEventSearchRequest { - /** Required page size validated and defaulted by the query service. */ - limit: number -} - -/** One lightweight event search hit with provider-produced evidence text. */ -export interface SessionEventSearchHit extends SessionEventRecord { - /** Plain-text excerpt explaining the match. */ - snippet: string -} - -/** One session-ranked search hit and its strongest matching event. */ -export interface SessionSearchHit extends SessionRecord { - /** Strongest matching event used as the session's ranking evidence. */ - bestMatch: SessionEventSearchHit -} - -/** One provider-owned page of search results. */ -export interface SessionSearchPage { - /** Stable id of the provider that produced this page. */ - providerId: string - /** Ranked hits in deterministic provider order, no longer than the requested limit. */ - items: readonly T[] - /** Opaque next-page cursor, absent when the result is exhausted. */ - nextCursor?: string -} - /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ @@ -150,8 +47,8 @@ export interface SessionEventReadRequest { /** Full target event and a bounded raw-log window. */ export interface SessionEventWindow { - /** Logical session metadata at read time. */ - session: SessionRecord + /** Cloned header for the live-preferred source read. */ + session: SessionHeader /** Full cloned target event. */ target: SessionEvent /** Full cloned events from `startSeq` through `endSeq`. */ @@ -161,144 +58,3 @@ export interface SessionEventWindow { /** Last seq included in `events`. */ endSeq: number } - -/** Recursive child node in a session lineage trace. */ -export interface SessionLineageNode { - /** Session represented by this lineage node. */ - session: SessionRecord - /** Direct children in deterministic creation order. */ - children: SessionLineageNode[] -} - -/** Complete known lineage around one session. */ -export interface SessionLineageTrace { - /** Session that was traced. */ - target: SessionRecord - /** Known parents from immediate parent outward. */ - parents: SessionRecord[] - /** Root when the complete parent chain is available. */ - root?: SessionRecord - /** First parent id outside the visible corpus, when the trace is partial. */ - unresolvedParentId?: SessionId - /** Complete known descendant forest rooted at the target's direct children. */ - children: SessionLineageNode[] -} - -/** Surface and provenance relationships for one event. */ -export interface SessionEventTrace { - /** Lightweight target record. */ - target: SessionEventRecord - /** Immediate replacement event that shadowed the target. */ - shadowedBy?: number - /** Replacement seqs from the target toward the current descendant. */ - replacementChain: number[] - /** Surface nodes directly shadowed by the target replacement event. */ - shadows: number[] - /** Direct provenance sources from `sourceEventSeqs`. */ - references: number[] - /** Events that directly name the target in `sourceEventSeqs`. */ - referencedBy: number[] -} - -/** Typed extractor for one declaration-merged session event type. */ -export interface SessionEventTextExtractor { - /** Stable cache-invalidation version chosen by the extractor owner. */ - version: string - /** - * Extract semantic searchable fragments from one event. - * @param event - event narrowed to the registered type. - * @returns plain-text fragments; blanks are discarded by the service. - */ - extract(event: SessionEvent): readonly string[] -} - -/** Typed extractor for one declaration-merged content block type. */ -export interface SessionContentTextExtractor { - /** Stable cache-invalidation version chosen by the extractor owner. */ - version: string - /** - * Extract semantic searchable fragments from one content block. - * @param block - block narrowed to the registered type. - * @returns plain-text fragments; blanks are discarded by the service. - */ - extract(block: ContentBlockMap[K]): readonly string[] -} - -/** One provider-neutral event document produced by registered extractors. */ -export interface SessionIndexDocument extends SessionEventRecord { - /** Normalized newline-joined text indexed by a search provider. */ - text: string -} - -/** One complete index layer for a live session or persisted checkpoint. */ -export interface SessionIndexSnapshot { - /** Layer metadata and live/persisted availability exposed in results. */ - session: SessionRecord - /** Stable SHA-256 identity of canonical source data and extractor versions. */ - fingerprint: string - /** Searchable event documents in seq order. */ - documents: readonly SessionIndexDocument[] -} - -/** Durable provider inventory entry used to reuse unchanged persisted rows. */ -export interface SessionPersistedIndexEntry { - /** Persisted session id. */ - sessionId: SessionId - /** Last indexed source/extractor fingerprint. */ - fingerprint: string -} - -/** Search and synchronization backend registered into `ctx.sessionQuery`. */ -export interface SessionSearchProvider { - /** Stable provider id, unique within the query service. */ - readonly id: string - /** - * Return cheap local usability without performing index or search I/O. - * @returns whether the provider can be selected. - */ - status(): SessionSearchProviderStatus - /** - * Read reusable persisted-layer fingerprints from derived storage. - * @returns durable inventory entries. - */ - persistedInventory(): Promise - /** - * Hide or expose reconciled persisted rows without deleting their cache. - * @param active - whether canonical persistence is mounted and reconciled. - */ - setPersistedActive(active: boolean): Promise - /** - * Atomically replace one persisted session's derived documents. - * @param snapshot - canonical persisted checkpoint and fingerprint. - */ - replacePersisted(snapshot: SessionIndexSnapshot): Promise - /** - * Delete one durable derived entry after canonical reconciliation proves it absent. - * @param sessionId - persisted id to remove. - */ - removePersisted(sessionId: SessionId): Promise - /** - * Replace one connection-local live override. - * @param snapshot - current live snapshot and availability. - */ - replaceLive(snapshot: SessionIndexSnapshot): Promise - /** - * Drop one live override, revealing its active persisted base when present. - * @param sessionId - live id to remove. - */ - removeLive(sessionId: SessionId): Promise - /** - * Search and group the complete logical corpus by session. - * @param request - query, pre-ranking filters, and pagination. - * @param exec - optional cancellation context. - * @returns one ranked session page. - */ - searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> - /** - * Search events within one logical session. - * @param request - target session, query, filters, and pagination. - * @param exec - optional cancellation context. - * @returns one ranked event page. - */ - searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> -} diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 36a8676640..29b863b678 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,39 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { CallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { - SessionQueryError, - filterEventResults, - filterSessionResults, + type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' -import type { - SessionEventSearchHit, - SessionEventSearchSpec, - SessionIndexSnapshot, - SessionQueryErrorCode, - SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchProviderStatus, - SessionSearchSpec, -} from '@deepseek-ai/dsh-session-query' - -declare module '@deepseek-ai/dsh-llm' { - interface ContentBlockMap { - 'test/text': { type: 'test/text'; value: string } - } -} - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/note': { note: string } - } -} function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } @@ -53,15 +25,13 @@ class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown static loadFailure: unknown - static listBarrier: Promise | undefined - static onList: (() => void) | undefined + static afterList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined this.loadFailure = undefined - this.listBarrier = undefined - this.onList = undefined + this.afterList = undefined } create(meta: SessionHeader): Promise { @@ -71,101 +41,23 @@ class TestPersistence extends SessionPersistence { append(id: SessionIdType, events: readonly SessionEvent[]): Promise { const entry = TestPersistence.entries.get(id) - if (entry === undefined) throw new Error('missing test session') + if (entry === undefined) return Promise.reject(new Error('missing test session')) entry.events.push(...structuredClone(events)) return Promise.resolve() } load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure)) + if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) return Promise.resolve(structuredClone(entry)) } list(): Promise { - if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) - const snapshot = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) - TestPersistence.onList?.() - return (TestPersistence.listBarrier ?? Promise.resolve()).then(() => snapshot) - } -} - -class FakeProvider implements SessionSearchProvider { - readonly id: string - statusValue: SessionSearchProviderStatus = { available: true } - persisted = new Map() - live = new Map() - activeHistory: boolean[] = [] - removedPersisted: SessionIdType[] = [] - removedLive: SessionIdType[] = [] - sessionRequests: SessionSearchSpec[] = [] - eventRequests: SessionEventSearchSpec[] = [] - failNextLive = false - failNextPersisted = false - sessionPage: SessionSearchPage - eventPage: SessionSearchPage - - constructor(id = 'fake') { - this.id = id - this.sessionPage = { providerId: id, items: [] } - this.eventPage = { providerId: id, items: [] } - } - - status(): SessionSearchProviderStatus { - return this.statusValue - } - - persistedInventory(): Promise { - return Promise.resolve([...this.persisted.values()].map(snapshot => ({ - sessionId: snapshot.session.header.id, - fingerprint: snapshot.fingerprint, - }))) - } - - setPersistedActive(active: boolean): Promise { - this.activeHistory.push(active) - return Promise.resolve() - } - - replacePersisted(snapshot: SessionIndexSnapshot): Promise { - if (this.failNextPersisted) { - this.failNextPersisted = false - return Promise.reject(new Error('persisted index failed')) - } - this.persisted.set(snapshot.session.header.id, structuredClone(snapshot)) - return Promise.resolve() - } - - removePersisted(sessionId: SessionIdType): Promise { - this.removedPersisted.push(sessionId) - this.persisted.delete(sessionId) - return Promise.resolve() - } - - replaceLive(snapshot: SessionIndexSnapshot): Promise { - if (this.failNextLive) { - this.failNextLive = false - return Promise.reject(new Error('live index failed')) - } - this.live.set(snapshot.session.header.id, structuredClone(snapshot)) - return Promise.resolve() - } - - removeLive(sessionId: SessionIdType): Promise { - this.removedLive.push(sessionId) - this.live.delete(sessionId) - return Promise.resolve() - } - - searchSessions(request: SessionSearchSpec): Promise> { - this.sessionRequests.push(structuredClone(request)) - return Promise.resolve(structuredClone(this.sessionPage)) - } - - searchEvents(request: SessionEventSearchSpec): Promise> { - this.eventRequests.push(structuredClone(request)) - return Promise.resolve(structuredClone(this.eventPage)) + if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure) + const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TestPersistence.afterList?.() + return Promise.resolve(headers) } } @@ -180,811 +72,181 @@ function expectCode(code: SessionQueryErrorCode): Error { return expect.objectContaining({ code }) as Error } -function asError(value: unknown): Error { - return value instanceof Error ? value : new Error(String(value)) +function rejectUnknown(reason: unknown): Promise { + return new Promise((_resolve, reject) => { + // Exercise containment for an implementation that violates the Error rejection convention. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(reason) + }) } -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { resolve = done }) - return { promise, resolve } -} - -describe('pure result filters', () => { - it('chains session filters as AND while values within one filter are OR', () => { - const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } - const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true } - const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true } - const input = [child, root, both] - - const output = filterSessionResults(input, [ - { kind: 'cwd', values: ['/a', '/b'] }, - { kind: 'created-at', range: { from: 2, to: 3 } }, - { kind: 'parent', values: [root.header.id] }, - { kind: 'availability', values: ['live', 'persisted'] }, - { kind: 'id', values: [child.header.id, both.header.id] }, - ]) - - expect(output).toEqual([child, both]) - expect(output[0]).toBe(child) - expect(input).toEqual([child, root, both]) - expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([]) - }) - - it('filters event ranges/types/status without reordering richer records', () => { - const events = [ - { sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true }, - { sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true }, - { sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true }, - ] - const output = filterEventResults(events, [ - { kind: 'seq', range: { from: 1, to: 2 } }, - { kind: 'time', range: { from: 10, to: 20 } }, - { kind: 'type', values: ['user/message', 'tool/call'] }, - { kind: 'surface', values: ['current', 'shadowed'] }, - ]) - expect(output).toEqual(events.slice(0, 2)) - expect(output[0]).toBe(events[0]) - }) - - it('rejects invalid serializable filter values', () => { - expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - }) - - it('handles absent range bounds and root/availability alternatives', () => { - const record: SessionRecord = { header: header('root'), live: false, persisted: true } - expect(filterSessionResults([record], [ - { kind: 'parent', values: [null] }, - { kind: 'cwd', values: [null] }, - { kind: 'availability', values: ['persisted'] }, - ])).toEqual([record]) - const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const } - expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event]) - }) -}) - -describe('logical corpus reads and traces', () => { - it('lists, classifies, reads, and traces a live session using detached records', async () => { - const ctx = await liveContext({ readWindowMax: 2 }) - const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } }) - const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } }) - const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] }) - const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] }) - const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] }) - - const listed = await ctx.sessionQuery.listSessions() - expect(listed).toEqual([{ header: session.header, live: true, persisted: false }]) - listed[0]!.header.createdAt = -1 - expect(session.header.createdAt).toBe(20) - expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface)) - .toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current']) - - const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 }) - expect([window.startSeq, window.endSeq]).toEqual([0, 4]) - expect(window.target.seq).toBe(answer.seq) - if (window.events[0]?.type !== 'user/message') throw new Error('expected user message') - window.events[0].data.content = [] - expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) - - await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({ - shadowedBy: summary.seq, - replacementChain: [summary.seq, resummary.seq], - referencedBy: [summary.seq], - }) - await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({ - shadows: [original.seq], - references: [original.seq], - referencedBy: [resummary.seq], - }) - await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] }) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - }) - - it('turns malformed replacement logs into typed surface failures', async () => { +describe('session-query exact reads', () => { + it('lists live sessions deterministically and returns detached headers', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('bad-surface')) - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { - surfaceOp: { op: 'replace', start: 9, end: 9 }, - sourceEventSeqs: [], - }) - await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) - }) - - it('returns complete, partial, deterministic, and cycle-checked lineage', async () => { - const ctx = await liveContext() - const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) - const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } }) - const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } }) - const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } }) - const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } }) - - const trace = await ctx.sessionQuery.traceSession(grandchild.id) - expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id]) - expect(trace.root?.header.id).toBe(root.id) - const rootTrace = await ctx.sessionQuery.traceSession(root.id) - expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id]) - expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id) - await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') }) - await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - - const cyclic = await liveContext() - const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') })) - const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') })) - cyclic.sessions.enter(a) - cyclic.sessions.enter(b) - await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) - }) - - it('uses live content over a matching persisted base and scopes persistence failures', async () => { - const common = header('same', 5, { cwd: '/w' }) - const persistedOnly = header('persisted', 1) - TestPersistence.reset([ - { meta: common, events: eventLog('persisted version') }, - { meta: persistedOnly, events: eventLog('persisted only') }, - ]) - const ctx = await liveContext() - const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } }) - live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const persistenceFiber = await ctx.plugin(TestPersistence) - await expect(ctx.sessionQuery.listEvents(SessionId('not-listed'))) - .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('z'), { meta: { createdAt: 2 } }) + ctx.sessions.create(SessionId('a'), { meta: { createdAt: 2 } }) const records = await ctx.sessionQuery.listSessions() - expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([ - [common.id, true, true], - [persistedOnly.id, false, true], - ]) - const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 }) - expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' }) - await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 })) - .resolves.toMatchObject({ session: { persisted: true } }) - - TestPersistence.listFailure = new Error('list unavailable') - await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1) - await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.listFailure = undefined - TestPersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_EVENT_NOT_FOUND') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - - await persistenceFiber.dispose() - TestPersistence.loadFailure = undefined - await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }]) + expect(records.map(record => record.header.id)).toEqual([SessionId('a'), SessionId('z'), older.id]) + expect(records.every(record => record.live && !record.persisted)).toBe(true) + records[2]!.header.createdAt = 99 + expect(older.header.createdAt).toBe(1) }) - it('rejects immutable source header conflicts', async () => { - TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }]) + it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() - ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } }) - await ctx.plugin(TestPersistence) - await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) - }) -}) - -describe('provider selection and synchronization', () => { - it('selects one usable provider, validates requests/pages, and disposes registration', async () => { - const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 }) - const session = ctx.sessions.create(SessionId('s')) - session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const dispose = ctx.sessionQuery.registerSearchProvider(provider) - const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false } - const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' } - provider.sessionPage = { providerId: provider.id, items: [ - { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, - ], nextCursor: 'next' } - - await expect(ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })) - .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) - expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) - expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') - await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) - provider.eventPage = { providerId: 'wrong', items: [] } - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) - - provider.eventPage = { providerId: provider.id, items: [] } - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) - .resolves.toMatchObject({ providerId: provider.id }) - - await dispose() - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - }) - - it('deselects immediately and drains accepted work before disposal settles', async () => { - const ctx = await liveContext() - const provider = new FakeProvider() - const queryStarted = deferred() - const releaseQuery = deferred() - provider.searchSessions = async () => { - queryStarted.resolve() - await releaseQuery.promise - return { providerId: provider.id, items: [] } - } - const dispose = ctx.sessionQuery.registerSearchProvider(provider) - - const accepted = ctx.sessionQuery.searchSessions({ query: 'accepted' }) - await queryStarted.promise - let disposed = false - const disposal = dispose().then(() => { disposed = true }) - await expect(ctx.sessionQuery.searchSessions({ query: 'future' })) - .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - await Promise.resolve() - expect(disposed).toBe(false) - - releaseQuery.resolve() - await expect(accepted).resolves.toMatchObject({ providerId: provider.id }) - await disposal - expect(disposed).toBe(true) - }) - - it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('coalesce')) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - let releaseLive!: () => void - const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) - const liveStarted = deferred() - let replacements = 0 - provider.replaceLive = async (snapshot) => { - replacements += 1 - liveStarted.resolve() - await liveBarrier - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - await liveStarted.promise - expect(replacements).toBe(1) - releaseLive() - await Promise.all([first, second]) - expect(replacements).toBe(2) - - session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - let releaseCorpus!: () => void - const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) - const corpusStarted = deferred() - let corpusReplacements = 0 - provider.replaceLive = async (snapshot) => { - corpusReplacements += 1 - corpusStarted.resolve() - await corpusBarrier - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) - const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) - await corpusStarted.promise - expect(corpusReplacements).toBe(1) - releaseCorpus() - await Promise.all([crossFirst, crossSecond]) - expect(corpusReplacements).toBe(2) - - let releaseSearch!: () => void - const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) - provider.searchSessions = async () => { - await searchBarrier - return { providerId: provider.id, items: [] } - } - const controller = new AbortController() - const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal }) - await Promise.resolve() - await Promise.resolve() - controller.abort() - await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - releaseSearch() - await Promise.resolve() - - provider.searchSessions = () => Promise.reject(new Error('search failed')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal })) - .rejects.toThrow('search failed') - }) - - it('holds a provider query stable until later reconciliation can begin', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('stable-query')) - session.append('user/message', { content: [{ type: 'text', text: 'stable' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const queryStarted = deferred() - const releaseQuery = deferred() - provider.searchEvents = async () => { - queryStarted.resolve() - await releaseQuery.promise - return { providerId: provider.id, items: [] } - } - const reconciliationStarted = deferred() - let reconciling = false - provider.setPersistedActive = (active) => { - if (!active) { - reconciling = true - reconciliationStarted.resolve() - } - return Promise.resolve() - } - ctx.sessionQuery.registerSearchProvider(provider) - - const eventSearch = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'stable' }) - await queryStarted.promise - const fullSearch = ctx.sessionQuery.searchSessions({ query: 'stable' }) - await new Promise((resolve) => { setImmediate(resolve) }) - expect(reconciling).toBe(false) - - releaseQuery.resolve() - await eventSearch - await reconciliationStarted.promise - expect(reconciling).toBe(true) - await fullSearch - }) - - it('reconciles a live removal observed while an older full sync is in flight', async () => { - const ctx = await liveContext() - const session = ctx.sessions.prepare(SessionId('removed-during-sync')) - const detach = ctx.sessions.enter(session) - ctx.sessions.announce(session) - session.append('user/message', { content: [{ type: 'text', text: 'stale live hit' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const replaceStarted = deferred() - const releaseReplace = deferred() - provider.replaceLive = async (snapshot) => { - replaceStarted.resolve() - await releaseReplace.promise - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const searchLiveIds: SessionIdType[][] = [] - provider.searchSessions = () => { - searchLiveIds.push([...provider.live.keys()]) - const items: SessionSearchHit[] = [] - for (const snapshot of provider.live.values()) { - const document = snapshot.documents[0] - if (document === undefined) continue - items.push({ - ...structuredClone(snapshot.session), - bestMatch: { ...structuredClone(document), snippet: document.text }, - }) - } - return Promise.resolve({ providerId: provider.id, items }) - } - ctx.sessionQuery.registerSearchProvider(provider) - - const first = ctx.sessionQuery.searchSessions({ query: 'stale' }) - await replaceStarted.promise - detach() - const second = ctx.sessionQuery.searchSessions({ query: 'stale' }) - releaseReplace.resolve() - - await first - await expect(second).resolves.toMatchObject({ items: [] }) - expect(provider.removedLive).toContain(session.id) - expect(searchLiveIds.at(-1)).toEqual([]) - }) - - it('searches a persisted target after corpus reconciliation', async () => { - const persisted = header('event-persisted', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' })) - .resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') - }) - - it('cancels a persisted-only event search while persistence listing is blocked', async () => { - const persisted = header('blocked-persisted-target', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) - const listStarted = deferred() - const releaseList = deferred() - TestPersistence.onList = listStarted.resolve - TestPersistence.listBarrier = releaseList.promise - const ctx = await liveContext() - const persistenceFiber = await ctx.plugin(TestPersistence) - await listStarted.promise - const provider = new FakeProvider() - const disposeProvider = ctx.sessionQuery.registerSearchProvider(provider) - const controller = new AbortController() - - const pending = ctx.sessionQuery.searchEvents( - { sessionId: persisted.id, query: 'target' }, - { signal: controller.signal }, + const session = ctx.sessions.create(SessionId('surface')) + const first = session.append( + 'user/message', + { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'draft' }, + }) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, + { surfaceOp: { op: 'replace', start: first.seq, end: first.seq } }, ) - controller.abort() - await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - expect(provider.eventRequests).toEqual([]) - releaseList.resolve() - await disposeProvider() - await persistenceFiber.dispose() - TestPersistence.listBarrier = undefined - TestPersistence.onList = undefined + expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) + .toEqual(['shadowed', 'log-only', 'current']) }) - it('normalizes non-Error query rejections and preserves Error identity', async () => { - const ctx = await liveContext() - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const signals = [undefined, new AbortController().signal] + it('returns a bounded detached raw-event window and validates the request', async () => { + const ctx = await liveContext({ readWindowMax: 1 }) + const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) + for (const text of ['one', 'two', 'three']) { + session.append( + 'user/message', + { content: [{ type: 'text', text }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + } - for (const [index, signal] of signals.entries()) { - const exec = signal === undefined ? undefined : { signal } - const identity = new Error(`query failure ${index}`) - provider.searchSessions = () => Promise.reject(identity) - const preserved = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) - .then(() => undefined, (error: unknown) => error) - expect(preserved).toBe(identity) + const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 }) + expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1]) + expect(result.session).toEqual(session.header) + result.session.createdAt = -1 + if (result.events[0]?.type !== 'user/message') throw new Error('expected user message') + result.events[0].data.content = [] + expect(session.header.createdAt).not.toBe(-1) + expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) - const rejection = { index } - // Deliberately violate the Promise convention to test the provider boundary. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - provider.searchSessions = () => Promise.reject(rejection) - const normalized = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) - .then(() => undefined, (error: unknown) => error) - expect(normalized).toBeInstanceOf(SessionQueryError) - expect(normalized).toMatchObject({ code: 'SESSION_QUERY_PROVIDER_ERROR', cause: rejection }) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) + .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + for (const request of [ + { sessionId: session.id, seq: 0, before: -1 }, + { sessionId: session.id, seq: 0, before: 2 }, + { sessionId: session.id, seq: 0, after: 0.5 }, + ]) { + await expect(ctx.sessionQuery.readEvent(request)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) } }) - it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { - const ctx = await liveContext() - const first = new FakeProvider('first') - ctx.sessionQuery.registerSearchProvider(first) - expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER')) - const second = new FakeProvider('second') - ctx.sessionQuery.registerSearchProvider(second) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS')) - - const configured = await liveContext({ searchProvider: 'chosen' }) - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')) - const chosen = new FakeProvider('chosen') - chosen.statusValue = { available: false, reason: 'unavailable' } - configured.sessionQuery.registerSearchProvider(chosen) - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')) - chosen.statusValue = { available: true } - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' }) - }) - - it('removes provider registrations with their contributing fiber', async () => { - const ctx = await liveContext() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped')) - }, { inject: ['sessionQuery'] })) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' }) - await fiber.dispose() - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - }) - - it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => { - const persisted = header('persisted', 1) - const overlaid = header('overlaid', 1) + it('merges authoritative persistence with live precedence and detects conflicts', async () => { + const shared = header('shared', 3, { cwd: '/same' }) + const durable = header('durable', 2) TestPersistence.reset([ - { meta: persisted, events: eventLog('persisted') }, - { meta: overlaid, events: eventLog('base') }, + { meta: shared, events: eventLog('persisted') }, + { meta: durable, events: eventLog('durable') }, ]) const ctx = await liveContext() - const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } }) - live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const persistenceFiber = await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) - ctx.sessionQuery.registerSearchProvider(provider) + const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const persistence = await ctx.plugin(TestPersistence) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') - expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') - expect(provider.live.get(overlaid.id)?.session).toMatchObject({ live: true, persisted: true }) - expect(provider.removedPersisted).toEqual([SessionId('stale')]) - expect(provider.activeHistory.at(-1)).toBe(true) - const fingerprint = provider.persisted.get(persisted.id)?.fingerprint - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint) + expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted])) + .toEqual([[shared.id, true, true], [durable.id, false, true]]) + const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 }) + expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0]) + .toMatchObject({ text: 'live' }) + await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ session: durable }) - const announced = header('announced', 3) - TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') }) - await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') - - await persistenceFiber.dispose() - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.activeHistory.at(-1)).toBe(false) - expect(provider.persisted.has(persisted.id)).toBe(true) + TestPersistence.entries.get(shared.id)!.meta.cwd = '/conflict' + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + await persistence.dispose() + await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([ + { header: shared, live: true, persisted: false }, + ]) }) - it('preserves persisted observations that race an older inventory listing', async () => { + it('keeps known live reads independent from persistence health', async () => { TestPersistence.reset() - const listStarted = deferred() - const releaseList = deferred() - TestPersistence.onList = listStarted.resolve - TestPersistence.listBarrier = releaseList.promise const ctx = await liveContext() - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) + const live = ctx.sessions.create(SessionId('live')) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) await ctx.plugin(TestPersistence) - await listStarted.promise + TestPersistence.listFailure = new Error('list unavailable') + TestPersistence.loadFailure = new Error('load unavailable') - const announced = header('racing-announcement', 3) - TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('after durable notification') }) - await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) - const search = ctx.sessionQuery.searchSessions({ query: 'notification' }) - releaseList.resolve() - - await expect(search).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('after durable notification') - TestPersistence.listBarrier = undefined - TestPersistence.onList = undefined + await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } }) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) - it('synchronizes only a live target for event search and retries dirty failures', async () => { + it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => { + const durable = header('durable') + TestPersistence.reset([{ meta: durable, events: eventLog() }]) const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('target')) - session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' }) - expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one') - session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - provider.failNextLive = true - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two']) - - const controller = new AbortController() - controller.abort() - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) - .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' })) + await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - }) - - it('removes a disposed live override and reveals the provider base', async () => { - const persisted = header('fallback', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('base') }]) - const ctx = await liveContext() await ctx.plugin(TestPersistence) - let session!: Session - const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } }) - session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - }, { inject: ['sessions'] })) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.live.has(session.id)).toBe(true) + await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - await liveFiber.dispose() - await Promise.resolve() - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.removedLive).toContain(session.id) - expect(provider.live.has(session.id)).toBe(false) - expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base') - }) - - it('retries failed persisted reconciliation without affecting canonical writes', async () => { - const persisted = header('retry', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - provider.failNextPersisted = true - ctx.sessionQuery.registerSearchProvider(provider) - - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') - }) - - it('types extractor failures during queued full synchronization', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('throwing-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const cause = new Error('custom extractor failed') - ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'throwing-v1', - extract: () => { throw cause }, - }) - - let thrown: unknown - try { - await ctx.sessionQuery.searchSessions({ query: 'x' }) - } catch (error: unknown) { - thrown = error + TestPersistence.loadFailure = 'raw failure' + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.loadFailure = undefined + TestPersistence.entries.get(durable.id)!.meta.cwd = '/changed-after-list' + TestPersistence.afterList = () => { + TestPersistence.entries.get(durable.id)!.meta.cwd = '/changed-during-read' } - expect(thrown).toBeInstanceOf(SessionQueryError) - expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) - expect(asError(thrown).message).toContain(`provider "${provider.id}"`) - expect(provider.sessionRequests).toEqual([]) + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('observes synchronous synchronization failure when the caller is already aborted', async () => { + it('turns malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('aborted-throwing-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'aborted-throwing-v1', - extract: () => { throw new Error('superseded extraction failure') }, - }) - const controller = new AbortController() - controller.abort() - const unhandled: unknown[] = [] - const onUnhandled = (reason: unknown) => { unhandled.push(reason) } - process.on('unhandledRejection', onUnhandled) - try { - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) - .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - await new Promise((resolve) => { setImmediate(resolve) }) - expect(unhandled).toEqual([]) - } finally { - process.off('unhandledRejection', onUnhandled) - } - }) - - it('types synchronous live-target extraction failures and leaves retries clean', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('throwing-live-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const cause = new Error('live extractor failed') - const disposeExtractor = ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'live-throwing-v1', - extract: () => { throw cause }, - }) - - let thrown: unknown - try { - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(SessionQueryError) - expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) - expect(asError(thrown).message).toContain(`provider "${provider.id}"`) - expect(provider.eventRequests).toEqual([]) - - disposeExtractor() - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })) - .resolves.toMatchObject({ providerId: provider.id }) - expect(provider.eventRequests).toHaveLength(1) - }) -}) - -describe('semantic text extractors', () => { - it('indexes core semantic text and excludes chunks and structural events', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('semantic')) - const nested: ContentBlock[] = [ - { type: 'text', text: 'visible' }, - { type: 'reasoning', text: 'thinking' }, - { type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' }, - { type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] }, - ] - session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' }) - session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }) - session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } }) - session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } }) - session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } }) - session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } }) - session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) - session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } }) - session.append('turn/end', { turn: 8, reason: { kind: 'completed' } }) - session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' }) - session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } }) - session.append('step/start', { turn: 1, step: 2 }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const documents = provider.live.get(session.id)?.documents ?? [] - expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) - expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result') - expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason') - expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED') - expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests') - expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL') - expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled') - expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail') - expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted') - expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk') - }) - - it('supports versioned effect-scoped custom event and content extractors', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('custom')) - session.append('test/note', { note: 'event note' }) - session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - let disposeEvent!: () => void - let disposeContent!: () => void - const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) - }, { inject: ['sessionQuery'] })) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const first = provider.live.get(session.id) - expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note']) - expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) - expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) - expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - - disposeEvent() - disposeContent() - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const second = provider.live.get(session.id) - expect(second?.documents).toEqual([]) - expect(second?.fingerprint).not.toBe(first?.fingerprint) - await extractorFiber.dispose() - - const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) - inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: block => [`replacement ${block.value}`] }) - }, { inject: ['sessionQuery'] })) - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const third = provider.live.get(session.id) - expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) - expect(third?.fingerprint).not.toBe(second?.fingerprint) - await replacementFiber.dispose() - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - expect(provider.live.get(session.id)?.documents).toEqual([]) - }) -}) - -describe('configuration', () => { - it('rejects an impossible default page size and exposes typed errors', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) - const error = new SessionQueryError('test', 'SESSION_QUERY_INVALID_CONFIG') - expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_INVALID_CONFIG' }) - }) - - it('uses constructor defaults and removes the service on plugin disposal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionQueryService) - const session = ctx.sessions.create(SessionId('defaults')) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await fiber.dispose() - expect(ctx.sessionQuery).toBeUndefined() + const session = ctx.sessions.create(SessionId('bad-surface')) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [] }, + { surfaceOp: { op: 'replace', start: 9, end: 9 } }, + ) + await expect(ctx.sessionQuery.listEvents(session.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) const direct = new Context() await direct.plugin(SessionStore) - const service = new SessionQueryService(direct, {}) - const directSession = direct.sessions.create(SessionId('direct-defaults')) - await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await direct.fiber.dispose() + expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService) + const invalid = new Context() + await invalid.plugin(SessionStore) + expect(() => new SessionQueryService(invalid, { readWindowMax: -1 })) + .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + }) + + it('leaves the optional persistence dependency optional', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionQueryService) + expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService) + await fiber.dispose() + expect(ctx.sessionQuery).toBeUndefined() }) }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0318e33fea..53ab0a9b3a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -113,9 +113,9 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'sessionQuery', pkg: 'session-query', - title: 'Session retrieval read model', + title: 'Exact session-history reads', mode: 'seam', - note: 'Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', }, { key: 'systemPrompt', @@ -531,7 +531,7 @@ function collectEventRelations(): Map { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text - if (!isCordisContextReceiver(node.expression)) { + if (!isCordisContextReceiver(node.expression, sf)) { ts.forEachChild(node, visit) return } @@ -561,12 +561,9 @@ function collectEventRelations(): Map { return out } -function isCordisContextReceiver(expr: ts.PropertyAccessExpression): boolean { - const receiver = expr.expression - if (ts.isIdentifier(receiver)) return receiver.text === 'ctx' || receiver.text === '_ctx' - return ts.isPropertyAccessExpression(receiver) - && receiver.expression.kind === ts.SyntaxKind.ThisKeyword - && (receiver.name.text === 'ctx' || receiver.name.text === '_ctx') +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 { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 32d8e91f6e..ba8cb64c6e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -36,36 +36,13 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistedChange", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryRange", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryExecContext", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProviderStatus", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionContentTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexDocument", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionPersistedIndexEntry", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProvider", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, From 42d85cf76f6a496c4d3c20822cbb7b435f8ab8b0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:29:19 +0800 Subject: [PATCH 18/31] fix(session): avoid retaining surface fold history --- packages/core/session/src/surface.ts | 27 +++++++++++---------- packages/core/session/tests/surface.spec.ts | 13 ++++++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 60c3633a1b..112faf0bd4 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -85,7 +85,6 @@ export interface SurfaceFoldResult { interface SurfaceFoldState { nodes: SurfaceNode[] nodeBySeq: Map - replacements: SurfaceFoldReplacement[] replaceGeneration: number } @@ -94,13 +93,15 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { nodes: [], nodeBySeq: new Map(), - replacements: [], replaceGeneration, } } -/** Apply one event to a surface fold state. */ -function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { +/** Apply one event and return replacement metadata only when one occurred. */ +function applySurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, +): SurfaceFoldReplacement | undefined { if (!isSurfaceEvent(event)) return if (event.surfaceOp === 'append') { @@ -112,13 +113,12 @@ function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { return } - const shadowedSeqs = replaceSurface(state, event.seq, event.surfaceOp) - state.replacements.push({ + return { seq: event.seq, start: event.surfaceOp.start, end: event.surfaceOp.end, - shadowedSeqs, - }) + shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp), + } } /** Apply one positional replacement and return the nodes it removed. */ @@ -170,13 +170,14 @@ function replaceSurface( */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() - for (const event of events) applySurfaceEvent(state, event) + const replacements: SurfaceFoldReplacement[] = [] + for (const event of events) { + const replacement = applySurfaceEvent(state, event) + if (replacement !== undefined) replacements.push(replacement) + } return { nodes: state.nodes.map(node => ({ ...node })), - replacements: state.replacements.map(replacement => ({ - ...replacement, - shadowedSeqs: [...replacement.shadowedSeqs], - })), + replacements, } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index e127b4a4b3..071bf1af86 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -33,6 +33,19 @@ describe('SurfaceManager', () => { expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) }) + it('does not retain fold-only replacement history in incremental state', () => { + const s = new Session(SessionId('incremental-state')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } }) + + expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }]) + const manager = s.surface as unknown as { _state: object } + expect(Object.hasOwn(manager._state, 'replacements')).toBe(false) + expect(foldSurface(s.events).replacements).toEqual([ + { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, + ]) + }) + it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { const s = new Session(SessionId('shared-fold-invalid')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) From bea27efc4bd7b54ee4224b80bf5200afdf433712 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sun, 12 Jul 2026 10:09:47 +0800 Subject: [PATCH 19/31] fix(session-query): reject markerless surface events --- packages/core/session/README.md | 2 +- packages/core/session/src/surface.ts | 7 ++++++- packages/core/session/tests/surface.spec.ts | 12 ++++++++++++ .../session-query/tests/session-query.spec.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 4de99ad961..99be514fe9 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -47,7 +47,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges. `SurfaceManager` shares the same transitions while retaining its incremental cache. +- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 112faf0bd4..3e4ce6d89c 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -102,7 +102,10 @@ function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, ): SurfaceFoldReplacement | undefined { - if (!isSurfaceEvent(event)) return + if (!isSurfaceEligibleType(event.type)) return + if (!isSurfaceEvent(event)) { + throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`) + } if (event.surfaceOp === 'append') { const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined @@ -167,6 +170,8 @@ function replaceSurface( * models cannot disagree with `deriveMessages()` about replacement ranges. * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. + * @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a + * replacement names nodes that are absent or reversed on the current surface. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 071bf1af86..1260b450a4 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -55,6 +55,18 @@ describe('SurfaceManager', () => { expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) }) + it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { + const malformed: SessionEvent = { + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } }, + } + + expect(() => foldSurface([malformed])) + .toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/) + }) + it('rebuilds a linked list from surfaceOp: append markers', () => { const s = surfaceSession() const nodes = s.surface.nodes diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 29b863b678..b449e59bb5 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -232,6 +232,21 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(session.id)) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + const persisted = header('bad-persisted-surface') + TestPersistence.reset([{ + meta: persisted, + events: [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } }, + }], + }]) + const persistence = await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listEvents(persisted.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + await persistence.dispose() + const direct = new Context() await direct.plugin(SessionStore) expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService) From 1a4ef8c8d957622f86c293309353ec8ba00f873c Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:34:05 -0700 Subject: [PATCH 20/31] =?UTF-8?q?docs(i18n):=20restructure=20terminology?= =?UTF-8?q?=20table=20=E2=80=94=20=E9=A6=96=E6=AC=A1=E5=87=BA=E7=8E=B0/?= =?UTF-8?q?=E4=B8=8D=E8=A6=81=E8=AF=91=E4=BD=9C=20as=20real=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自由文本备注里的「首次出现可写:」「不要译作:」提为独立列,机器可 直接按列执行;表头补各列语义。按评审意见收录 counterpart、sidecar record(禁「旁挂记录」)、source of truth 裁定行,agent 组合词与 seam 括注规则细化。 --- docs/i18n/terminology.md | 282 ++++++++++++++++++++------------------- 1 file changed, 145 insertions(+), 137 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 0166b87e4c..408048a66f 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -1,140 +1,148 @@ # Terminology -本表约定本仓库的中英术语统一译法。 +本表约定本仓库的中英术语统一译法。各列语义: -| English | 中文 | 备注 | -|---|---|---| -| ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | -| AI | AI | 首次出现可写:人工智能(AI) | -| API | API | real-API 作定语时可译「真实接口」(如 real-API tests → 真实接口测试) | -| CI | CI | | -| CLI | CLI | 首次出现可写:命令行界面(CLI) | -| Cordis | Cordis | 保留英文 | -| Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) | -| HMR | HMR | 首次出现可写:热模块替换(HMR) | -| JSON Schema | JSON Schema | | -| JSONL | JSONL | | -| lint | lint | | -| 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 哈希 | git 对象哈希;`git hash-object` 的结果 | -| commit hash | 提交哈希 | | -| hash | 哈希 | 代码与命令中保留英文(如 `git hash-object`、`` 占位符) | -| doc-sync | doc-sync | 仓库门禁名,保留英文 | -| e2e | e2e | | -| fiber | fiber | 首次出现可写:fiber(插件运行时) | -| fixture | fixture | 指测试前置数据或环境 | -| fork | fork | 保留英文 | -| harness | harness | 保留英文 | -| manifest | manifest | 描述模块或工具元数据的文件 | -| monorepo | monorepo | | -| package | package | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | -| schema DSL | schema DSL | | -| schema | schema | 保留英文 | -| seam | seam | 首次出现可写:seam(扩展点) | -| skill | skill | 首次出现可写:skill(技能) | -| spawn | spawn | 保留英文 | -| steering | steering | 首次出现可写:steering(中途引导) | -| subagent | subagent | 首次出现可写:subagent(子 agent) | -| transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | -| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) | -| worktree | worktree | git 工作区概念,保留英文 | -| wire format | 协议格式 | 首次出现可写:协议格式(wire format) | -| adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) | -| adapter | 适配器 | | -| append-only | 仅追加 | | -| artifact | 产物 | | -| block | 块 | | -| background task | 后台任务 | | -| backend | 后端 | | -| capability | 能力 | | -| cancel | 取消 | | -| checkpoint | 检查点 | | -| chunk | 分片 | | -| compaction | compaction | 首次出现可写:compaction(上下文压缩);正文优先保留英文 | -| consumer | 消费方 | | -| content block | 内容块 | | -| 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 | 消息 | | -| mock | mock | 保留英文;指测试替身 | -| mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | -| model provider | 模型提供方 | | -| module | 模块 | | -| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿。进程语境按 OS 惯用语译「孤儿进程」 | -| pairing | 配对 | | -| permission | 权限 | | -| persistence | 持久化 | | -| pipeline | 流水线 | | -| plugin | 插件 | mod 对应“模组” | -| prompt | 提示词 | | -| provider | 提供方 | | -| provider-neutral | 提供方无关 | | -| quality gate | 质量门禁 | | -| registry | 注册表 | | -| reasoning | 推理(reasoning) | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” | -| replay | 回放 | | -| resume | 恢复 | | -| runtime | 运行时 | | -| sandbox | 沙箱 | | -| 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 用量 | | -| thinking | thinking | API 字段保留;模型模式译为“思考” | -| tool | 工具 | | -| tool call | 工具调用 | | -| tool result | 工具结果 | | -| tool schema | 工具 schema | | -| toolkit | 工具包 | | -| turn | 轮次 | | -| typecheck | 类型检查 | | -| vocabulary | 词汇 | | -| workflow | 工作流 | | +- **中文** — 正文中的固定译法;写「保留英文」表示不译。 +- **首次出现** — 术语在一篇文档中第一次出现时的完整写法(含括注);之后只写「中文」列的形式。组合词已括注过的成分,单独出现时不再括注。 +- **不要译作** — 禁用译法,逐项列出;出现任何一项即评审阻断。 +- **备注** — 语境区分、裁定出处等自由说明;不承载可执行规则。 + +| English | 中文 | 首次出现 | 不要译作 | 备注 | +|---|---|---|---|---| +| ACP | ACP | ACP(Agent Client Protocol) | | | +| AI | AI | 人工智能(AI) | | | +| API | API | | | real-API 作定语时可译「真实接口」(如 real-API tests → 真实接口测试) | +| CI | CI | | | | +| CLI | CLI | 命令行界面(CLI) | | | +| Cordis | Cordis | | | 保留英文 | +| Function Calling | Function Calling | Function Calling(函数调用) | | | +| HMR | HMR | 热模块替换(HMR) | | | +| JSON Schema | JSON Schema | | | | +| JSONL | JSONL | | | | +| lint | lint | | | | +| 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 单独首现处;组合词见下行 | +| agent harness, agent workflow, agent loop, agent skill | 保留英文 | | | agent 组合词整体保留英文、不括注;其中的 workflow/loop/skill 不单独拆译;未列出的 agent 组合词同此处理 | +| backlog | backlog | | | 双语翻译语境指待翻清单 | +| blob hash | blob 哈希 | | | git 对象哈希;`git hash-object` 的结果 | +| commit hash | 提交哈希 | | | | +| hash | 哈希 | | | 代码与命令中保留英文(如 `git hash-object`、`` 占位符) | +| doc-sync | doc-sync | | | 仓库门禁名,保留英文 | +| e2e | e2e | | | | +| fiber | fiber | fiber(插件运行时) | | | +| fixture | fixture | | | 指测试前置数据或环境 | +| fork | fork | | | 保留英文 | +| harness | harness | | | 保留英文 | +| manifest | manifest | | | 描述模块或工具元数据的文件 | +| monorepo | monorepo | | | | +| package | package | | | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | +| schema DSL | schema DSL | | | | +| schema | schema | | | 保留英文 | +| seam | seam | seam(扩展点) | | 同句已出现「扩展点」(extension point)时不加括注,直接写 seam,避免一词两指 | +| skill | skill | skill(技能) | | | +| spawn | spawn | | | 保留英文 | +| steering | steering | steering(中途引导) | | | +| subagent | subagent | subagent(子 agent) | | | +| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | +| waterfall | waterfall | waterfall(瀑布式事件) | | | +| worktree | worktree | | | git 工作区概念,保留英文 | +| wire format | 协议格式 | 协议格式(wire format) | | | +| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | | +| adapter | 适配器 | | | | +| append-only | 仅追加 | | | | +| artifact | 产物 | | | | +| block | 块 | | | | +| background task | 后台任务 | | | | +| backend | 后端 | | | | +| capability | 能力 | | | | +| cancel | 取消 | | | | +| checkpoint | 检查点 | | | | +| chunk | 分片 | | | | +| compaction | compaction | compaction(上下文压缩) | | 正文优先保留英文 | +| consumer | 消费方 | | | | +| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指“另一侧”时可写「另一侧」 | +| content block | 内容块 | | | | +| 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 | 消息 | | | | +| mock | mock | | | 保留英文;指测试替身 | +| mod | 模组 | | | 区别于 module(模块);plugin 译作「插件」 | +| model provider | 模型提供方 | | | | +| module | 模块 | | | | +| orphan | 孤立 | | 孤儿 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;进程语境按 OS 惯用语译「孤儿进程」 | +| pairing | 配对 | | | | +| permission | 权限 | | | | +| persistence | 持久化 | | | | +| pipeline | 流水线 | | | | +| plugin | 插件 | | | mod 对应“模组” | +| prompt | 提示词 | | | | +| provider | 提供方 | | | | +| provider-neutral | 提供方无关 | | | | +| quality gate | 质量门禁 | | | | +| registry | 注册表 | | | | +| reasoning | 推理(reasoning) | | | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” | +| replay | 回放 | | | | +| resume | 恢复 | | | | +| runtime | 运行时 | | | | +| sandbox | 沙箱 | | | | +| service | 服务 | | | | +| session | 会话 | | | | +| session event | 会话事件 | | | | +| sidecar record | 伴随记录 | | 旁挂记录 | 指 `.i18n.yaml` 这类随主文件存放的记录文件 | +| smoke test | 冒烟测试 | | | | +| snapshot | 快照 | | | | +| source of truth | 真源 | 真源(source of truth) | | 评审裁定译法;如后续裁定调整,改此表即可 | +| spine | 主干 | | | | +| staged | 暂存 | | | git 官方中文同译 | +| stale | 陈旧 | | | MDN HTTP 缓存中文同译,与「新鲜(fresh)」成对;门禁输出保留英文 `stale`;expired 才译「过期」 | +| step | 步骤 | | | | +| stream | 流 | | | | +| streaming | 流式输出 | | | | +| structural signature | 结构签名 | | | i18n 机制词:配对门禁比对的有序结构序列 | +| system prompt | 系统提示词 | | | | +| taxonomy | 分类体系 | | | | +| token usage | token 用量 | | | | +| thinking | thinking | | | API 字段保留;模型模式译为“思考” | +| tool | 工具 | | | | +| tool call | 工具调用 | | | | +| tool result | 工具结果 | | | | +| tool schema | 工具 schema | | | | +| toolkit | 工具包 | | | | +| turn | 轮次 | | | | +| typecheck | 类型检查 | | | | +| vocabulary | 词汇 | | | | +| workflow | 工作流 | | | | From 8ad6dbc3596fcbeb46da1c6383e7a7663ea2895a Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:34:19 -0700 Subject: [PATCH 21/31] docs(i18n): commit the pipeline translation prompt as a repo asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动翻译流水线的 prompt 模板进仓(docs/i18n/translation-prompt.md): 占位符契约(source_lang/target_lang/terminology 三个,旧 %% 分段协议 废弃)+ 逐字模板正文(三段 XML 输出、分类自审、8 组正误例)。列入 配对排除清单;skill 与流水线自此共用同一套规则文件。 --- .agents/skills/dsh-translate-docs/SKILL.md | 1 + docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 1 + docs/i18n/README.zh.md | 1 + docs/i18n/translation-prompt.md | 178 +++++++++++++++++++++ scripts/translation-pairing.manifest.json | 3 +- 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 docs/i18n/translation-prompt.md diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 8a2b231e8f..1a5cc03549 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -14,6 +14,7 @@ These are authoritative; read them at the source so this skill never drifts out - **[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. +- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's prompt template (placeholder contract + the verbatim prompt body). Agents following THIS skill do not render that template; it exists so the pipeline and this skill share one set of rules — a rule change lands in both or it is a bug. ## Find the work diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 84cc3e7977..3c954439ce 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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: f28676b7d555aca30d8b5b21eba9fb4222949763 -README.zh.md: 5353a8ba04288cf72defa30b5896731b3d419b69 +README.md: 94c8ad9e3a83b490a854dce69b27bd7e13d834b5 +README.zh.md: 4c66da76feff1b1a6c175da62cbd986838b3577e diff --git a/docs/i18n/README.md b/docs/i18n/README.md index f28676b7d5..94c8ad9e3a 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -42,6 +42,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co - `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` and [style-samples.md](style-samples.md) — both are bilingual by construction. +- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. **Rollout**: new documents don't wait for a batch — a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so everything new is bilingual from birth. Files dated before the cutoff are the grandfathered backlog by definition — including files created on the cutoff's eve — and a document's filename date is its first-proposed date per the RFC convention, so backdating past the cutoff is a review-visible violation, not a loophole. For the back-catalog, 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. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 5353a8ba04..4c66da76fe 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -42,6 +42,7 @@ - `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。 +- [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 **推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。日期早于 cutoff 的文件按定义属于被豁免的存量——包括 cutoff 前夜创建的文件——而文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过 cutoff 是评审可见的违规,不是漏洞。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md new file mode 100644 index 0000000000..db9b78aaee --- /dev/null +++ b/docs/i18n/translation-prompt.md @@ -0,0 +1,178 @@ +# Translation prompt (pipeline asset) + +本文件是自动翻译流水线使用的 prompt 模板,正文(自 `# Translation Prompt` 起)逐字进入模型请求,不参与双语配对(见 [README.md](README.md) 排除清单)。模板与仓库规则的关系:[terminology.md](terminology.md) 在渲染时整表填入 `{{terminology}}`;文体金标见 [style-samples.md](style-samples.md),模板内嵌的 Examples 是其中问题类别的最小抽样,两者冲突时以 style-samples 为准。修改本文件即修改线上翻译行为,按正常 PR 评审。 + +## 占位符契约 + +流水线渲染模板时替换以下占位符,除此之外不做任何文本处理: + +| 占位符 | 填入内容 | 来源 | +|---|---|---| +| `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` | +| `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 | +| `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | + +历史模板的 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 占位符与 `%%` 分段协议已废弃:本模板按整文档翻译(非分段),输出协议为下方三段 XML。 + +## 模板正文 + +````text +# Translation Prompt + +You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose. + +## Quality Requirements + +### Structure and Format Preservation +- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks. +- Fenced code blocks must be byte-identical to the source, including comments. Do not translate any content inside code fences. +- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. +- Every relative link must point to the same target as in the source. Link text is translated; link targets are not. +- After a closing bold marker `**`, always insert a space before the next character. + +### Tone and Style +- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. +- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. +- Use polite imperative forms where the text instructs the reader to do something. +- Keep the author's register: concise stays concise, detailed stays detailed. + +### Sentence Structure +- Break long sentences with commas or semicolons. Avoid run-on sentences. +- Prefer active voice. Convert passive constructions to active if it reads more naturally. +- Translate meaning, not words. Restructure sentences where the target language grammar requires it. +- Do not invent words or expressions that do not exist in natural technical writing of the target language. + +### Word Choice +- Prefer precise, formal vocabulary over casual or colloquial alternatives. +- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language. +- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience. +- Do not use the same word to translate two different source-language terms that carry distinct meanings. +- Avoid repeating the same verb in close proximity; vary word choice for readability. + +### Punctuation + +#### When translating into Chinese +- Use full-width Chinese punctuation in prose: `,。:;?!()「」`. +- Replace em-dashes (——) with colons, periods, commas, or parentheses as appropriate. Only keep em-dashes when they are truly the best choice. +- Use enumeration commas (、) between parallel items, not regular commas. +- List item endings: use semicolons or no punctuation. Do not end list items with commas. +- Put one half-width space between Chinese text and Latin words/numbers. +- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. + +#### When translating into English +(To be added.) + +## Terminology + +A terminology table is provided below. Follow it strictly: +- Render every listed term exactly as specified. +- First occurrence: write as shown in the "首次出现" column (with parenthetical gloss). Subsequent occurrences: write only the part before the parentheses. +- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later. +- NEVER use translations listed in the "不要译作" column. +- For technical terms not in the table: keep them in the source language. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression. + +{{terminology}} + +## Output Format + +Produce your output in three XML sections: + +```xml + +(Complete translation of the source document) + + + +(Self-review notes, one correction per line with category tag, e.g.) +- [Tone] "旁挂记录" → "伴随记录"(生造词) +- [Sentence] 第 3 段补充逗号断句 +- [Punctuation] 两处破折号替换为冒号 +- 无修正 + + + +(Final translation after corrections) + +``` + +## Self-Review Instructions + +After writing ``, re-read it in the target language only, without looking at the source. Check by category: + +**Structure** +- Is the heading hierarchy, list shape, and code block content identical to the source? +- Are link targets preserved and bold markers followed by a space? + +**Tone & Style** +- Does every sentence read as if originally written by a native speaker? +- Is there any colloquial, casual, or overly informal phrasing? + +**Sentence Structure** +- Are there run-on sentences that need breaking? +- Are there stiff passive constructions that should be converted to active voice? + +**Word Choice** +- Are there overly literal translations that sound unnatural? +- Is the same target-language word used to translate two distinct source concepts? +- Is any slang or internal jargon present? + +**Terminology** +- Are first-occurrence glosses correctly applied (not missing, not repeated)? +- Are any "不要译作" forbidden translations present? +- Are unlisted terms correctly kept in the source language? + +**Punctuation** (when target is Chinese) +- Are there em-dashes that should be replaced with colons, periods, or commas? +- Are list items ending with commas instead of semicolons? +- Are RFC 2119 keywords rendered in italics? + +Record corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write "无修正" in `` and copy the translation unchanged into ``. + +## Examples + +Below are representative examples of common problems and their corrections. Follow the "Good" versions. + +### Colloquial verb → Professional verb +- Source: `The repo pins pnpm@11.7.0 in package.json` +- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0` +- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0` + +### Run-on sentence → Natural phrasing with pause +- Source: `Read docs/architecture.md before changing anything under packages/.` +- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。` +- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。` + +### Stiff passive voice → Active and natural +- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.` +- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。` +- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。` + +### Invented word → Natural expression +- Source: `A sidecar record of both blob hashes makes consistency checkable` +- Bad: `旁挂记录两侧 blob hash,使一致性可检查` +- Good: `伴随记录保存两侧 blob hash,使一致性可检查` + +### Em-dash → Colon/period +- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.` +- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。` +- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` + +### Overly literal → Meaningful rendering +- Source: `awkward phrasing is easier to hear without the source anchoring you` +- Bad: `没有源文锚着,别扭的表述更容易被听出来` +- Good: `不对照原文时,更容易察觉别扭的表达` + +### Terminology — do not translate what should be kept in English +- Source: `typed service seams, and explicit extension points` +- Bad: `类型化的服务 seam(扩展点)与显式扩展点` +- Good: `类型化的服务 seam 与显式扩展点` + +### Slang/jargon → Professional phrasing +- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs` +- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs` +- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs` + +--- + +Now translate the following document: +```` diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 6f05431ced..77d7936822 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -13,6 +13,7 @@ "docs/cordis-catalog/", "docs/tool-catalog/", "docs/i18n/terminology.md", - "docs/i18n/style-samples.md" + "docs/i18n/style-samples.md", + "docs/i18n/translation-prompt.md" ] } From c74cc5a6099c79d62a5c0ba7a19b3d136dc3cc15 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:41:10 -0700 Subject: [PATCH 22/31] docs(i18n): credit the prompt and few-shot examples to jingtingxiang --- docs/i18n/translation-prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index db9b78aaee..573f125878 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -1,6 +1,6 @@ # Translation prompt (pipeline asset) -本文件是自动翻译流水线使用的 prompt 模板,正文(自 `# Translation Prompt` 起)逐字进入模型请求,不参与双语配对(见 [README.md](README.md) 排除清单)。模板与仓库规则的关系:[terminology.md](terminology.md) 在渲染时整表填入 `{{terminology}}`;文体金标见 [style-samples.md](style-samples.md),模板内嵌的 Examples 是其中问题类别的最小抽样,两者冲突时以 style-samples 为准。修改本文件即修改线上翻译行为,按正常 PR 评审。 +本文件是自动翻译流水线使用的 prompt 模板,正文(自 `# Translation Prompt` 起)逐字进入模型请求,不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与其内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写。模板与仓库规则的关系:[terminology.md](terminology.md) 在渲染时整表填入 `{{terminology}}`;文体金标见 [style-samples.md](style-samples.md),模板内嵌的 Examples 是其中问题类别的最小抽样,两者冲突时以 style-samples 为准。修改本文件即修改线上翻译行为,按正常 PR 评审。 ## 占位符契约 From ba4e39f10d52507c05c31e852bd9622bb905b784 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:07:45 -0700 Subject: [PATCH 23/31] docs(i18n): adopt jingtingxiang's calibrated terminology table and five gold pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 术语表整体采用 jingtingxiang 的重构版(PR #244):按缩写类/英文类/ 双语类分节、通用规则前置、首次出现与不要译作独立列;保留本 stack 的 mock/package/counterpart 与 agent 组合词裁定行。五组金标译文按 其定稿采用(development、i18n README、translation-rules、双语 RFC、 根 README),README.zh 吸收 PR #249 的两处措辞(「智能体辔架」括注 与其自身新表冲突,未采用)。translation-prompt.md 增补 Few-shot 金 标一节:声明这 5 组配对即流水线的整文档级 few-shot,注入方式为多轮 示例对话。style-samples 的 blob hash 译法按新表回改为保留英文。 Supersedes the .zh.md content of #244/#249/#264. --- README.i18n.yaml | 2 +- README.zh.md | 4 +- docs/development.i18n.yaml | 2 +- docs/development.zh.md | 58 ++-- docs/i18n/style-samples.md | 2 +- docs/i18n/terminology.md | 301 +++++++++--------- docs/i18n/translation-prompt.md | 12 + docs/i18n/translation-rules.i18n.yaml | 2 +- docs/i18n/translation-rules.zh.md | 62 ++-- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 2 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 42 +-- 11 files changed, 262 insertions(+), 227 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 0a981d4323..b765b44217 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 7ddf68bab06ecf891856e6d1393ccdefd9eeba38 -README.zh.md: 59a0419164f2dfee6f66903cc93d7b35da1d9063 +README.zh.md: ad568c808d4c8003a84734bc8035fac9a07d27b4 diff --git a/README.zh.md b/README.zh.md index 59a0419164..ad568c808d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ ## 开发 -本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:一切皆插件。 +本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:所有功能都以插件形式提供。 ```sh pnpm install @@ -15,6 +15,6 @@ 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)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 +面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d8f76162db..ac9ee414b1 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write development.md: 28babca2b59c844690750d767c242c08c37bf702 -development.zh.md: b34c52cebd3f333b6554ca2b5ebd66463e436572 +development.zh.md: 91b63d7511f856f21162f8ab66eae6e25d925059 diff --git a/docs/development.zh.md b/docs/development.zh.md index b34c52cebd..91b63d7511 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,14 +2,14 @@ [English](development.md) | 中文 -本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁。 +本指南覆盖参与 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`。 +- 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 测试。 +- 可选:一个 DeepSeek API key,用于 REPL/ACP agent 演示和真实 API 的 e2e 测试。 ## 首次搭建 @@ -19,23 +19,23 @@ pnpm install ``` -安装同时会运行根目录的 `postinstall` 脚本,它通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook;该包装脚本使用 lefthook 经过评审的 `--force` 模式,使已存在 `core.hooksPath` 的关联 worktree 不会让正常的 `pnpm run …` 命令失败。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。 -如果因为依赖是从缓存恢复或 `postinstall` 被跳过而缺少钩子,手动安装: +如果依赖是从缓存恢复或 `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 设置下被检查。 +首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 -如果准备从新克隆或新 worktree 推送,还要构建一次: +如果准备从新克隆或新 worktree 推送,还需要构建一次: ```sh pnpm run build @@ -45,29 +45,29 @@ pnpm run build ## 环境变量 -真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证: +真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证: ```sh DEEPSEEK_API_KEY=sk-... DEEPSEEK_BASE_URL=https://... # optional ``` -`DEEPSEEK_BASE_URL` 可选,默认为公开 API。绝不要提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 +`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 ## Git 钩子 lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: -- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。 +- `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`。 +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 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 echo-agent 和 built-bin 冒烟测试,并在 Node 24 和 26 上运行矩阵。 ## CI 门禁 -GitHub 工作流在每个 pull request 上运行这些门禁: +GitHub 工作流在每个 Pull Request 上运行以下门禁: - `pnpm install --frozen-lockfile` - `pnpm run constraints` @@ -82,7 +82,7 @@ GitHub 工作流在每个 pull request 上运行这些门禁: - 一个 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 脚本。 +`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 脚本。 ## 日常命令 @@ -96,9 +96,13 @@ 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 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 @@ -109,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 ``` -改动 package 的公开行为时,在同一个变更里更新相关 README 或 JSDoc。`pnpm run doc-sync` 能抓住被检查的 TypeScript 片段、cordis 事件/服务目录漂移和硬折行的 markdown 段落,但更广泛的行文/API 同步仍需评审把关。 +修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。 ## 演示 @@ -133,24 +137,24 @@ pnpm run demo:acp ## TODO 标记 -用三种注释标签之一标记代码中的已知问题,按紧急程度排序: +请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: -- `FIXME`——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。 -- `TODO`——应当尽快修复的问题,等资源到位就处理。 -- `XXX`——也许某天会修的问题;优先级最低,不作承诺。 +- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`; +- `TODO`:应当尽快修复的问题,等资源到位即可处理; +- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。 -选择与紧急程度匹配的标签,让扫代码的人一眼分清「发布阻塞」和「有空再说」。 +请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。 ## 逐字记录类型(`ts type-equiv`) -[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,把它围栏成 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[核心数据结构](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。 +`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(扩展点)与显式扩展点构建。 +在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index b72d810afc..6376b3964f 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -54,7 +54,7 @@ > 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. -系统采用文件 blob 哈希而非提交哈希记录状态。同一 PR 内修改文件时,可通过 `git hash-object foo.md` 直接算出对应哈希,仅对比文件内容即可判断双语文档是否同步。通过记录的哈希值,可使用 `git cat-file -p ` 还原上次确认对齐时两侧的原文。当双语文档不一致时,只需对比修改版本与上次确认版本的差异,最小幅度同步修改另一侧译文,无需全文重新翻译。 +系统采用文件 blob hash 而非 commit hash 记录状态。同一 PR 内修改文件时,可通过 `git hash-object foo.md` 直接算出对应 blob hash,仅对比文件内容即可判断双语文档是否同步。通过记录的 blob hash,可使用 `git cat-file -p ` 还原上次确认对齐时两侧的原文。当双语文档不一致时,只需对比修改版本与上次确认版本的差异,最小幅度同步修改另一侧译文,无需全文重新翻译。 ## ⑤ 政策声明 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 408048a66f..ae6f24acdb 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -1,148 +1,165 @@ # Terminology -本表约定本仓库的中英术语统一译法。各列语义: +本表约定本仓库的中英术语统一译法。 -- **中文** — 正文中的固定译法;写「保留英文」表示不译。 -- **首次出现** — 术语在一篇文档中第一次出现时的完整写法(含括注);之后只写「中文」列的形式。组合词已括注过的成分,单独出现时不再括注。 -- **不要译作** — 禁用译法,逐项列出;出现任何一项即评审阻断。 -- **备注** — 语境区分、裁定出处等自由说明;不承载可执行规则。 +**通用规则:** +- "中文"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。 +- 首次出现按"首次出现"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。 +- "不要译作"列为严格禁止的译法。 +- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。 + +## 缩写类(中英文文本中均使用缩写) | English | 中文 | 首次出现 | 不要译作 | 备注 | |---|---|---|---|---| -| ACP | ACP | ACP(Agent Client Protocol) | | | -| AI | AI | 人工智能(AI) | | | -| API | API | | | real-API 作定语时可译「真实接口」(如 real-API tests → 真实接口测试) | -| CI | CI | | | | -| CLI | CLI | 命令行界面(CLI) | | | -| Cordis | Cordis | | | 保留英文 | -| Function Calling | Function Calling | Function Calling(函数调用) | | | -| HMR | HMR | 热模块替换(HMR) | | | -| JSON Schema | JSON Schema | | | | -| JSONL | JSONL | | | | -| lint | lint | | | | -| 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 单独首现处;组合词见下行 | -| agent harness, agent workflow, agent loop, agent skill | 保留英文 | | | agent 组合词整体保留英文、不括注;其中的 workflow/loop/skill 不单独拆译;未列出的 agent 组合词同此处理 | -| backlog | backlog | | | 双语翻译语境指待翻清单 | -| blob hash | blob 哈希 | | | git 对象哈希;`git hash-object` 的结果 | -| commit hash | 提交哈希 | | | | -| hash | 哈希 | | | 代码与命令中保留英文(如 `git hash-object`、`` 占位符) | -| doc-sync | doc-sync | | | 仓库门禁名,保留英文 | -| e2e | e2e | | | | -| fiber | fiber | fiber(插件运行时) | | | -| fixture | fixture | | | 指测试前置数据或环境 | -| fork | fork | | | 保留英文 | -| harness | harness | | | 保留英文 | -| manifest | manifest | | | 描述模块或工具元数据的文件 | -| monorepo | monorepo | | | | -| package | package | | | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | -| schema DSL | schema DSL | | | | -| schema | schema | | | 保留英文 | -| seam | seam | seam(扩展点) | | 同句已出现「扩展点」(extension point)时不加括注,直接写 seam,避免一词两指 | -| skill | skill | skill(技能) | | | -| spawn | spawn | | | 保留英文 | -| steering | steering | steering(中途引导) | | | -| subagent | subagent | subagent(子 agent) | | | -| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | -| waterfall | waterfall | waterfall(瀑布式事件) | | | -| worktree | worktree | | | git 工作区概念,保留英文 | -| wire format | 协议格式 | 协议格式(wire format) | | | -| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | | -| adapter | 适配器 | | | | -| append-only | 仅追加 | | | | -| artifact | 产物 | | | | -| block | 块 | | | | -| background task | 后台任务 | | | | -| backend | 后端 | | | | -| capability | 能力 | | | | -| cancel | 取消 | | | | -| checkpoint | 检查点 | | | | -| chunk | 分片 | | | | -| compaction | compaction | compaction(上下文压缩) | | 正文优先保留英文 | -| consumer | 消费方 | | | | -| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指“另一侧”时可写「另一侧」 | -| content block | 内容块 | | | | -| 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 | 消息 | | | | -| mock | mock | | | 保留英文;指测试替身 | -| mod | 模组 | | | 区别于 module(模块);plugin 译作「插件」 | -| model provider | 模型提供方 | | | | -| module | 模块 | | | | -| orphan | 孤立 | | 孤儿 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;进程语境按 OS 惯用语译「孤儿进程」 | -| pairing | 配对 | | | | -| permission | 权限 | | | | -| persistence | 持久化 | | | | -| pipeline | 流水线 | | | | -| plugin | 插件 | | | mod 对应“模组” | -| prompt | 提示词 | | | | -| provider | 提供方 | | | | -| provider-neutral | 提供方无关 | | | | -| quality gate | 质量门禁 | | | | -| registry | 注册表 | | | | -| reasoning | 推理(reasoning) | | | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” | -| replay | 回放 | | | | -| resume | 恢复 | | | | -| runtime | 运行时 | | | | -| sandbox | 沙箱 | | | | -| service | 服务 | | | | -| session | 会话 | | | | -| session event | 会话事件 | | | | -| sidecar record | 伴随记录 | | 旁挂记录 | 指 `.i18n.yaml` 这类随主文件存放的记录文件 | -| smoke test | 冒烟测试 | | | | -| snapshot | 快照 | | | | -| source of truth | 真源 | 真源(source of truth) | | 评审裁定译法;如后续裁定调整,改此表即可 | -| spine | 主干 | | | | -| staged | 暂存 | | | git 官方中文同译 | -| stale | 陈旧 | | | MDN HTTP 缓存中文同译,与「新鲜(fresh)」成对;门禁输出保留英文 `stale`;expired 才译「过期」 | -| step | 步骤 | | | | -| stream | 流 | | | | -| streaming | 流式输出 | | | | -| structural signature | 结构签名 | | | i18n 机制词:配对门禁比对的有序结构序列 | -| system prompt | 系统提示词 | | | | -| taxonomy | 分类体系 | | | | -| token usage | token 用量 | | | | -| thinking | thinking | | | API 字段保留;模型模式译为“思考” | -| tool | 工具 | | | | -| tool call | 工具调用 | | | | -| tool result | 工具结果 | | | | -| tool schema | 工具 schema | | | | -| toolkit | 工具包 | | | | -| turn | 轮次 | | | | -| typecheck | 类型检查 | | | | -| vocabulary | 词汇 | | | | -| workflow | 工作流 | | | | +| ACP | ACP | ACP(Agent Client Protocol) | | | +| AI | AI | AI(人工智能) | | | +| API | API | | | | +| CI | CI | | | | +| CLI | CLI | CLI(命令行界面) | | | +| e2e | e2e | | | | +| HMR | HMR | HMR(热模块替换) | | | +| JSON Schema | JSON Schema | | | | +| JSONL | JSONL | | | | +| LLM | LLM | LLM(大语言模型) | | | +| MCP | MCP | | | | +| PR | PR | PR(Pull Request) | | | +| RAG | RAG | RAG(检索增强生成) | | | +| SDK | SDK | | | | +| SSE | SSE | SSE(Server-Sent Events) | | | + +## 英文类(中英文文本中均使用英文) + +| English | 中文 | 首次出现 | 不要译作 | 备注 | +|---|---|---|---|---| +| agent | agent | agent(智能体) | | | +| agent harness | agent harness | | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按 agent 行处理 | +| agent loop | agent loop | agent loop(智能体循环) | | | +| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` | +| blob hash | blob hash | | | `git hash-object` 的结果 | +| Cordis | Cordis | | | | +| dispose | dispose | dispose(资源释放) | | | +| doc-sync | doc-sync | doc-sync(文档同步门禁) | | | +| fiber | fiber | fiber(插件运行时) | | | +| fixture | fixture | fixture(测试前置数据) | | | +| fork | fork | | | | +| Function Calling | Function Calling | Function Calling(函数调用) | | | +| harness | harness | | | | +| harness engineering | harness engineering | | | | +| lint | lint | | | | +| mock | mock | | | 保留英文;指测试替身 | +| loader | loader | | | | +| manifest | manifest | manifest(元数据清单) | | | +| monorepo | monorepo | | | | +| package | package | | | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | +| schema | schema | | | | +| schema DSL | schema DSL | | | | +| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | +| skill | skill | skill(技能) | | | +| spawn | spawn | | | | +| steering | steering | steering(中途引导) | | | +| subagent | subagent | | | | +| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` | +| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | +| waterfall | waterfall | waterfall(瀑布式事件) | | | +| worktree | worktree | | | git 工作区概念 | + +## 双语类(中英文文本各自使用中英文) + +| English | 中文 | 首次出现 | 不要译作 | 备注 | +|---|---|---|---|---| +| adapter | 适配器 | | | | +| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | | +| append-only | 仅追加 | | | | +| artifact | 产物 | | | | +| backend | 后端 | | | | +| background task | 后台任务 | | | | +| block | 块 | | | | +| cancel | 取消 | | | | +| capability | 能力 | | | | +| checkpoint | 检查点 | | | | +| chunk | 分片 | | | | +| compaction | 压缩 | 压缩(compaction) | | | +| config | 配置 | | | | +| consumer | 消费方 | | | | +| content block | 内容块 | | | | +| context | 上下文 | | | | +| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指"另一侧"时可写「另一侧」 | +| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | | +| contract | 契约 | | | 如:`pairing contract` →`配对契约` | +| coverage | 覆盖率 | | | | +| crash recovery | 崩溃恢复 | | | | +| durability | 持久性 | | | | +| enforcement frontier | 强制边界 | | | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围 | +| event | 事件 | | | | +| event log | 事件日志 | | | | +| event stream | 事件流 | | | | +| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| executor | 执行器 | | | | +| extension | 扩展 | | | | +| extension point | 扩展点 | | | 注意与 `seam` 区分 | +| fail-fast | 快速失败 | | | | +| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | +| fingerprint | 指纹 | | | i18n 配对机制用语:`.zh.md` 首行记录英文源 blob hash 的注释 | +| finish reason | 结束原因 | | | | +| foreground run | 前台运行 | | | | +| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 | +| hook | 钩子 | | | | +| implementation | 实现 | | | | +| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 | +| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 | +| injection | 注入 | | | | +| integration | 集成 | | | | +| interface | 接口 | | | | +| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 | +| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` | +| merge | 合并 | | | | +| message | 消息 | | | | +| mod | 模组 | | | | +| model provider | 模型提供方 | | | | +| module | 模块 | | | | +| orphan | 孤立 | | 孤儿 | 指英文源已不存在的 `.zh.md` | +| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | +| pairing | 配对 | | | | +| permission | 权限 | | | | +| persistence | 持久化 | | | | +| pipeline | 流水线 | | | | +| plugin | 插件 | | | | +| prompt | 提示词 | | | | +| provider | 提供方 | | | | +| provider-neutral | 提供方无关 | | | | +| quality gate | 质量门禁 | | | | +| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 | +| reasoning_content | 思考内容 | | | | +| registry | 注册表 | | | | +| replay | 回放 | | | | +| resume | 恢复 | | | | +| runtime | 运行时 | | | | +| sandbox | 沙箱 | | | | +| service | 服务 | | | | +| session | 会话 | | | | +| session event | 会话事件 | | | | +| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 | +| smoke test | 冒烟测试 | | | | +| snapshot | 快照 | | | | +| source of truth | 真源 | | | | +| spine | 主干 | | | | +| staged | 暂存 | | | 沿用 git 官方中文翻译 | +| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | +| step | 步骤 | | | | +| stream | 流 | | | | +| streaming | 流式输出 | | | | +| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) | +| system prompt | 系统提示词 | | | | +| taxonomy | 分类体系 | | | | +| token usage | token 用量 | | | | +| tool | 工具 | | | | +| tool call | 工具调用 | | | | +| tool result | 工具结果 | | | | +| tool schema | 工具 schema | | | | +| toolkit | 工具包 | | | | +| turn | 轮次 | | | | +| typecheck | 类型检查 | | | | +| vocabulary | 词汇 | | | | +| wire format | 协议格式 | 协议格式(wire format) | | | +| workflow | 工作流 | | | | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 573f125878..db707730fa 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -14,6 +14,18 @@ 历史模板的 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 占位符与 `%%` 分段协议已废弃:本模板按整文档翻译(非分段),输出协议为下方三段 XML。 +## Few-shot 金标 + +流水线的 few-shot 是**整文档级**的中英对照,不是模板内嵌的句子级正误例(那是最小抽样)。few-shot 集取自以下 5 组人工定稿的配对文档,以仓库当前版本为准、随仓库更新: + +- `README.md` ↔ `README.zh.md` +- `docs/development.md` ↔ `docs/development.zh.md` +- `docs/i18n/README.md` ↔ `docs/i18n/README.zh.md` +- `docs/i18n/translation-rules.md` ↔ `docs/i18n/translation-rules.zh.md` +- `docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md` ↔ 对应 `.zh.md` + +注入方式:在系统消息(本模板)之后、待译文档之前,每组作为一轮示例对话——user 消息为源文档全文,assistant 消息为定稿译文全文(不带三段 XML 包装;只有真实请求要求三段输出)。上下文紧张时按上列顺序从后往前裁剪组数。这 5 组也是评审校准锚点(见 [style-samples.md](style-samples.md)),改动任何一组即改变流水线行为。 + ## 模板正文 ````text diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index f1aa737d08..d340ff42b8 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write translation-rules.md: 3e99aa5432ccd904f702238e9b802a9ed9bf6832 -translation-rules.zh.md: 78778907f959d62dad2c9b4c02baf1f664791a43 +translation-rules.zh.md: 0ff2ab43d59c6e0dd07e20e2f1a0a1c28c2f245c diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 78778907f9..0ff2ab43d5 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -2,13 +2,13 @@ [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)**自行裁量。 +本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [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)** 自行裁量。 ## 忠实性 -- 另一侧必须说撰写侧所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜:改正错的那一侧,并在同一个变更里把另一侧带上。 -- 另一侧应当读起来是其语言自然的技术文字,而不是逐词对照。翻译语义,在目标语言语法需要处重组句子,并保持原作者的语域——简练的保持简练。 -- 不要翻译不可译的东西:一句话如果依赖源语言的习语而无法自然转换,就翻译它的意思,而不是习语本身。 +- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。 +- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。 +- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。 ## 行文 @@ -23,47 +23,47 @@ 形状由配对门禁强制,译者不需要用流畅度去换结构——在框架内自然行文即可。配对的两个文件必须在以下方面一一对应: -- 标题层级(相同级别、相同顺序——标题的**文字**要翻译), -- 列表形态与编号, -- 表格(相同的列、相同的行序;表头单元格按术语表翻译), -- 围栏代码块——**逐字节一致,包括注释**;代码属于受验证的范围(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, -- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, -- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标——按约定是 `.md` 路径而非 `.zh.md` 兄弟文件——这样某对文档先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 +- 标题层级(相同级别、相同顺序;标题的**文字**要翻译); +- 列表形态与编号; +- 表格(相同的列、相同的行序;表头单元格按术语表翻译); +- 围栏代码块:**逐字节一致,包括注释**。代码属于受验证的范围(` ```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)。 +- [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: +本节规则约束中文一侧;英文一侧遵循仓库常规的 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`。 +- *必须*在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。 +- 中文行文*必须*使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。 +- 顿号:中文的并列项之间使用顿号(、),而非逗号。 +- *禁止*使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。 +- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。 - 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。 -- 强调标记(`**加粗**`、`*斜体*`)落在与另一侧相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 +- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。 -## 质量线 +## 质量标准 -- 一对文档的完成标准:一位双语工程师只读其中任一文件,得到与另一文件读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 -- 交付前,对照本文自查一遍,并**只读另一侧**再通读一遍、不看源侧对照;没有源文锚着,别扭的表述更容易被听出来。 -- 机械契约(一致性记录、切换行、结构、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 +- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。 +- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。 +- 机械契约(一致性记录、切换行、结构、折行、链接)由 `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《翻译服务译文质量要求》——国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 +- [中文文案排版指北](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/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 index 1ef023ec50..f409941924 100644 --- 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 @@ -3,4 +3,4 @@ # 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: 764ad5a9345c2a138b56e9cedb54848a5f7d6054 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f039fbe3fa44cb379905bbea9a4692af19d71230 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: c752d76f12f556ce190bf80c4f3a531c0821be8e 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 index f039fbe3fa..c752d76f12 100644 --- 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 @@ -1,36 +1,38 @@ -# 通过配对兄弟文件与配对门禁实现双语文档 +# RFC:通过配对兄弟文件与配对门禁实现双语文档 + +Status: implemented [English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文 -## 背景 +## 问题 -本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。没有机制、纯靠手工维护第二语言,正是译文腐烂的方式:一侧继续演进,另一侧默默地说谎,而没有门禁会注意到。对这类不变式,本仓库一贯的答案是把它编码成机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。 +本仓库的 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 承载工作流,并把真源让给文档。 +- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `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 时间戳(无记录)**——否决:纯格式化的改动会误报,一次无关改动之后提交的另一侧会漏报;只有内容同一性这个信号与门禁的承诺名实相符。 +- **英文为正典源、指纹放在译文内**:本 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 服务。 +带语言后缀的配对兄弟文件是中国大厂的主流约定(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。新文档是例外——文件名日期在 manifest `requiredSince` 当天或之后的文档,要么连同配对一起合入,要么不合入,因此 backlog 只会缩小。 -- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),所以这套机制从不强迫整篇重译。 +- 修改已配对文档的任一侧,同一个 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 的最小更新),因此这套机制从不强迫整篇重译。 From 1583cb47a088834fd5a698665cde24773575bc4c Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:14:47 -0700 Subject: [PATCH 24/31] docs(i18n): reconcile gold pairs with master's EN-side drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 金标译文写于 0710 的 master,英文侧此后有漂移,按最小更新补齐: development.zh 的 Node 引擎下限句、CI 门禁节改写为八任务车道描述、 代码块与英文侧逐字节同步;README.zh 恢复 graph-atlas 链接; style-samples 的目录链接随 events-and-services.md 拆分改指 events.md。 requiredSince 推至 2026-07-14:0705–0713 间已合入的两篇无配对 RFC 成为存量(原分界在其合入时未生效,回溯会红在与本 PR 无关的文件上)。 --- README.i18n.yaml | 2 +- README.zh.md | 2 +- docs/development.i18n.yaml | 2 +- docs/development.zh.md | 22 +++++-------------- docs/i18n/style-samples.md | 4 ++-- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- scripts/translation-pairing.manifest.json | 2 +- 7 files changed, 13 insertions(+), 25 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index db27519f15..37a95ccdac 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # 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 +README.zh.md: 2119dabf0ae2d2e16e274e44fda7cbbaec146dc9 diff --git a/README.zh.md b/README.zh.md index ad568c808d..2119dabf0a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -15,6 +15,6 @@ 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)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 +面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index c1e557170d..face2eeeaa 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write development.md: bd6f6b561480419abea7a42a44b4078e2c59b1cb -development.zh.md: 54bf19765d2b4dc419e6b71684dbcfcd28230541 +development.zh.md: 61b75b8128ac40b4483debff8aeaeabcfe98752f diff --git a/docs/development.zh.md b/docs/development.zh.md index 91b63d7511..61b75b8128 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -6,7 +6,7 @@ ## 前置条件 -- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上运行矩阵测试。 +- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git。 - 可选:一个 DeepSeek API key,用于 REPL/ACP agent 演示和真实 API 的 e2e 测试。 @@ -63,26 +63,13 @@ lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点 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 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 ## CI 门禁 -GitHub 工作流在每个 Pull Request 上运行以下门禁: +无密钥 GitHub 工作流共有八个任务:五条 Node 24 车道分别运行静态门禁、lint、覆盖率、快照回放与产物门禁;三个兼容性任务在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。车道调度器从 `package.json` 展开相互独立的门禁:constraints、typecheck、lint、覆盖率、快照回放、`doc-sync` 各成员、module-graph 新鲜度、`knip`,以及 echo-agent 冒烟测试。 -- `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 脚本。 +`pnpm run build` 供给产物车道;`publint`、`verify-node-next-types` 与 built-bin 冒烟测试等待构建产出。独立的真实 API 工作流使用密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。 ## 日常命令 @@ -98,6 +85,7 @@ 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 verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc 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 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 6376b3964f..174a5612ff 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,9 +14,9 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-core`,它的职责是组装整套实体主干。 -> This document covers **behavior**; type shapes live in [core-data-structures/](../core-data-structures/core.md), the per-event/service reference in the [generated catalog](../cordis-catalog/events-and-services.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). +> This document covers **behavior**; type shapes live in [core-data-structures/](../core-data-structures/core.md), the per-event/service reference in the [generated catalog](../cordis-catalog/events.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). -本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](../core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](../cordis-catalog/events-and-services.md);各 package 对外约束协议写在对应包的 README([索引](../../packages/README.md))。 +本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](../core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](../cordis-catalog/events.md);各 package 对外约束协议写在对应包的 README([索引](../../packages/README.md))。 ## ② 防御模式规则 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 index ecbd6f196b..f20250c3ba 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 8731fef46b16cfa20d223575c70774cff780a6aa -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: ce2589498ab16cf5ca2f5cdb3f58d031aeb8298f +2026-07-02-bilingual-docs-and-pairing-gate.md: 1e96622e7fb5694ab61772d68744394ef1aeb53a +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: c752d76f12f556ce190bf80c4f3a531c0821be8e diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 891aae375b..f45659ea4b 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,5 +1,5 @@ { - "requiredSince": "2026-07-05", + "requiredSince": "2026-07-14", "required": [ "README.md", "docs/development.md", From 8befd9b88c295381a86c260e2efc11577fce2965 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:45:26 -0700 Subject: [PATCH 25/31] =?UTF-8?q?docs(i18n):=20harden=20the=20pipeline=20p?= =?UTF-8?q?rompt=20=E2=80=94=20switcher=20flip,=20code-comment=20hard=20ru?= =?UTF-8?q?le,=20em-dash=20preference,=20three=20new=20few-shots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/i18n/translation-prompt.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index db707730fa..2082d83da5 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -37,9 +37,10 @@ You are a senior technical translator specializing in LLM and agent development ### Structure and Format Preservation - Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks. -- Fenced code blocks must be byte-identical to the source, including comments. Do not translate any content inside code fences. +- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions. - Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. - Every relative link must point to the same target as in the source. Link text is translated; link targets are not. +- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. - After a closing bold marker `**`, always insert a space before the next character. ### Tone and Style @@ -65,7 +66,7 @@ You are a senior technical translator specializing in LLM and agent development #### When translating into Chinese - Use full-width Chinese punctuation in prose: `,。:;?!()「」`. -- Replace em-dashes (——) with colons, periods, commas, or parentheses as appropriate. Only keep em-dashes when they are truly the best choice. +- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all. - Use enumeration commas (、) between parallel items, not regular commas. - List item endings: use semicolons or no punctuation. Do not end list items with commas. - Put one half-width space between Chinese text and Latin words/numbers. @@ -113,6 +114,8 @@ After writing ``, re-read it in the target language only, without l **Structure** - Is the heading hierarchy, list shape, and code block content identical to the source? +- Are ALL comments inside code blocks left untranslated (byte-identical to source)? +- Is the language switcher line correctly flipped (not copied from source)? - Are link targets preserved and bold markers followed by a space? **Tone & Style** @@ -184,6 +187,21 @@ Below are representative examples of common problems and their corrections. Foll - Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs` - Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs` +### "For humans" — translate the intent, not the word +- Source: `For humans, start with the development guide` +- Bad: `对于人工读者,请先从开发指南开始`("人工读者"生硬) +- Good: `面向开发者:请先阅读开发指南`("开发者"自然,且中文里冒号在此处更自然) + +### Code block comments — NEVER translate +- Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)` +- Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)` +- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte) + +### Language switcher — flip direction +- Source file (English) has: `English | [中文](README.zh.md)` +- Bad (copying source unchanged): `English | [中文](README.zh.md)` +- Good (flipped for Chinese file): `[English](README.md) | 中文` + --- Now translate the following document: From 8a53f3d6f9e74652ebeafd17061e6fdcc0252ed0 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:10:43 -0700 Subject: [PATCH 26/31] =?UTF-8?q?docs(i18n):=20address=20PR=20#172=20revie?= =?UTF-8?q?w=20=E2=80=94=20terminology=20rulings=20and=20register=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #172 评审意见整改(修复落在本 stack 上层,随 stack 合入): enforcement frontier 裁定为「执行红线」(与金标样例⑦一致), 「强制边界」入不要译作列,消除 translation-rules 与术语表的矛盾; orphan 裁定为「遗留」(崔),孤立/孤儿均入禁项,orphan branch 仍 沿用 git 官方「孤立分支」;README.zh 推进段与 translation-rules.zh 的门禁句按金标语体重写(崔);style-samples ⑥ 的「文件内容完全一 致」改为准确描述 blob hash 与伴随记录的比对机制(bot)。 --- docs/i18n/README.i18n.yaml | 2 +- docs/i18n/README.zh.md | 2 +- docs/i18n/style-samples.md | 2 +- docs/i18n/terminology.md | 4 ++-- docs/i18n/translation-rules.i18n.yaml | 2 +- docs/i18n/translation-rules.zh.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 3c954439ce..f058b57082 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 94c8ad9e3a83b490a854dce69b27bd7e13d834b5 -README.zh.md: 4c66da76feff1b1a6c175da62cbd986838b3577e +README.zh.md: 5ed960a4dbf48d3f9b2f681298fd4d7487ca3eaf diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 4c66da76fe..5ed960a4db 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -44,7 +44,7 @@ - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 -**推进**:新文档不等批次——文件名带日期的文档(`yyyy-mm-dd-*.md`,即 RFC)日期在 manifest 的 `requiredSince` 当天或之后,就必须连同配对一起合入,新增的一切生来即是双语。日期早于 cutoff 的文件按定义属于被豁免的存量——包括 cutoff 前夜创建的文件——而文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过 cutoff 是评审可见的违规,不是漏洞。对于存量文档,manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 +**推进**:新增文档不再等待批量翻译。以日期命名的文档(`yyyy-mm-dd-*.md`,即 RFC),只要标注日期等于或晚于 manifest 里的 `requiredSince` 分界日期,合入时就必须配齐双语配对——所有新文档从创建起就要求双语齐备。日期早于分界的文件按定义属于豁免的存量(包括分界前夜创建的文件);文件名日期按 RFC 惯例即首次提出日期,倒填日期绕过分界属于评审可见的违规,构不成漏洞。对于存量文档,manifest 中的 `required` 列表只是当下的执行红线,并非最终目标;目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、实操手册(cookbook)、RFC、事故复盘(postmortem)等);每个批次合入后把对应文件加进 `required`,门禁只向前收紧、不倒退放宽。尚未进入 `required` 的文档是待翻清单(backlog),可通过 `--list` 查看;但任何已存在的配对,无论是否在清单内,都按完整契约检查。为一篇文档建立配对等同于一份长期承诺:此后修改任一侧,都必须同步更新另一侧。因此执行红线的推进节奏要匹配翻译评审的实际投入,切勿超前铺开。 ## 分工 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 174a5612ff..e97cc331a9 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -66,7 +66,7 @@ > 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. -对比双语文件的 git 时间戳(无哈希记录方案)——不予采纳:仅调整格式的改动会触发误报,无关修改后再提交译文又会造成漏检。只有文件内容完全一致,才能作为门禁可信的校验依据。 +对比双语文件的 git 时间戳(无记录方案)——不予采纳:仅调整格式的改动会触发误报,无关修改后再提交译文又会造成漏检。只有基于内容本身的标识(每侧文件的 blob hash 与伴随记录比对),才能承载门禁所声称的语义。 ## ⑦ 推进策略(长段拆分示范) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index ae6f24acdb..1da19d30a8 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -90,7 +90,7 @@ | coverage | 覆盖率 | | | | | crash recovery | 崩溃恢复 | | | | | durability | 持久性 | | | | -| enforcement frontier | 强制边界 | | | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围 | +| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 | | event | 事件 | | | | | event log | 事件日志 | | | | | event stream | 事件流 | | | | @@ -118,7 +118,7 @@ | mod | 模组 | | | | | model provider | 模型提供方 | | | | | module | 模块 | | | | -| orphan | 孤立 | | 孤儿 | 指英文源已不存在的 `.zh.md` | +| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | | pairing | 配对 | | | | | permission | 权限 | | | | diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index d340ff42b8..048ce5eef7 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write translation-rules.md: 3e99aa5432ccd904f702238e9b802a9ed9bf6832 -translation-rules.zh.md: 0ff2ab43d59c6e0dd07e20e2f1a0a1c28c2f245c +translation-rules.zh.md: 1d8dcb04f1015af2db46c2e25b44f5cd147b6ecc diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 0ff2ab43d5..1d8dcb04f1 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -21,7 +21,7 @@ ## 结构保持 -形状由配对门禁强制,译者不需要用流畅度去换结构——在框架内自然行文即可。配对的两个文件必须在以下方面一一对应: +结构一致性由配对门禁负责校验,译者无需为保持结构而牺牲行文的流畅:在既定框架内自然书写即可。配对的两个文件必须在以下方面一一对应: - 标题层级(相同级别、相同顺序;标题的**文字**要翻译); - 列表形态与编号; From 624d8d5df4e0016e14f92185a72e93de5c862ff9 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 12 Jul 2026 19:57:33 +0800 Subject: [PATCH 27/31] refactor(examples): fold the sandbox composition into acp-agent as its variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox-acp-agent's whole surface moves into examples/acp-agent following the example's existing multi-variant shape (the code-mode/both-mode overlays): sandbox.cordis.yml + its replay overlay, the four snapshot scenarios (config-options / mode-switching / escalation-approved / escalation-rejected) as a 'sandbox' header class over per-scenario configPath — goldens byte-identical, zero re-recording — and escalation.e2e.ts unchanged apart from the config path. One ACP example remains; demo:sandbox-acp keeps its name and boots the variant. References (both RFCs, group/package READMEs, the examples table and test map, the e2e workflow comment) now point at the variant. --- .github/workflows/e2e.yml | 2 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../implemented/feature/2026-07-06-sandbox.md | 8 +- examples/AGENTS.md | 3 +- examples/README.md | 6 +- examples/acp-agent/README.md | 13 ++- .../sandbox.cordis.snapshot.yml} | 2 +- .../sandbox.cordis.yml} | 8 +- examples/acp-agent/tests/acp.snapshot.ts | 35 ++++++++ .../tests/escalation.e2e.ts | 10 +-- .../tests/snapshots/config-options/input.json | 0 .../snapshots/config-options/session.jsonl | 0 .../config-options/stdout.golden.jsonl | 0 .../snapshots/escalation-approved/input.json | 0 .../escalation-approved/session.jsonl | 0 .../escalation-approved/stdout.golden.jsonl | 0 .../snapshots/escalation-rejected/input.json | 0 .../escalation-rejected/session.jsonl | 0 .../escalation-rejected/stdout.golden.jsonl | 0 .../tests/snapshots/mode-switching/input.json | 0 .../snapshots/mode-switching/session.jsonl | 0 .../mode-switching/stdout.golden.jsonl | 0 .../mode-switching/system-prompt.golden.md | 0 .../mode-switching/workspace/notes.txt | 0 examples/sandbox-acp-agent/README.md | 16 ---- examples/sandbox-acp-agent/package.json | 7 -- .../sandbox-acp-agent/tests/acp.snapshot.ts | 82 ------------------- package.json | 2 +- packages/bash/README.md | 2 +- packages/bash/bash-sandbox/README.md | 2 +- packages/sandbox/README.md | 2 +- packages/sandbox/sandbox-local/README.md | 2 +- 32 files changed, 70 insertions(+), 134 deletions(-) rename examples/{sandbox-acp-agent/cordis.snapshot.yml => acp-agent/sandbox.cordis.snapshot.yml} (97%) rename examples/{sandbox-acp-agent/cordis.yml => acp-agent/sandbox.cordis.yml} (90%) rename examples/{sandbox-acp-agent => acp-agent}/tests/escalation.e2e.ts (96%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/config-options/input.json (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/config-options/session.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/config-options/stdout.golden.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-approved/input.json (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-approved/session.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-approved/stdout.golden.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-rejected/input.json (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-rejected/session.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/escalation-rejected/stdout.golden.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/mode-switching/input.json (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/mode-switching/session.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/mode-switching/stdout.golden.jsonl (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/mode-switching/system-prompt.golden.md (100%) rename examples/{sandbox-acp-agent => acp-agent}/tests/snapshots/mode-switching/workspace/notes.txt (100%) delete mode 100644 examples/sandbox-acp-agent/README.md delete mode 100644 examples/sandbox-acp-agent/package.json delete mode 100644 examples/sandbox-acp-agent/tests/acp.snapshot.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e06fb025bd..9111f50153 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -82,7 +82,7 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The with-key escalation e2e (examples/sandbox-acp-agent/tests/ + # The with-key escalation e2e (examples/acp-agent/tests/ # escalation.e2e.ts) self-skips without a usable runner — without this # step it would never actually execute anywhere (CI had no bwrap, dev # macs run Seatbelt instead), which is exactly how a broken harness diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 5d82226a96..f853a123e1 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -23,7 +23,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in [the sandbox example](../../../../examples/sandbox-acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. +The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in [the acp-agent example's sandbox variant](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index e3e740b31c..366e0adada 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -16,7 +16,7 @@ One seam, one per-platform chain of local backends, one consumer, and two levers ### How a deployment uses it -Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; [`examples/sandbox-acp-agent`](../../../../examples/sandbox-acp-agent/README.md) is this composition, live: +Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; [`examples/acp-agent`'s sandbox variant](../../../../examples/acp-agent/README.md) (`sandbox.cordis.yml`) is this composition, live: ```yaml - id: sandbox @@ -133,9 +133,9 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine ### Testing - Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. -- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (`examples/sandbox-acp-agent`): the real `cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. -- With-key e2e (`examples/sandbox-acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. +- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (the acp-agent example's `escalation.e2e.ts`): the real `sandbox.cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. +- With-key e2e (`examples/acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). +- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. ## Deferred phases diff --git a/examples/AGENTS.md b/examples/AGENTS.md index d54104a189..35b250bbfb 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -22,7 +22,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/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | | `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject | -| `sandbox-acp-agent` | `escalation.e2e.ts` — boots the real tree (sandbox + approval + bridge) keyless: initialize + `session/new` | same file — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | -| `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 | +| `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/escalation.e2e.ts` boots the sandbox variant's real tree (sandbox + approval + bridge) keyless: initialize + `session/new` | `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; `tests/escalation.e2e.ts` — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | 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 7f711cb297..431052f992 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,8 +33,4 @@ An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. -## sandbox-acp-agent - -The coding agent with its bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local) + [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) — the one-entry executor swap the `ctx.bash` capability seam exists for), served over ACP with [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval) mounted — the first composition where the approval loop is LIVE: a sandbox denial escalated by the model becomes a `session/request_permission` prompt in the editor, and "Allow once" runs exactly that command under the wider mode. - -Run with: `pnpm run demo:sandbox-acp` (needs `DEEPSEEK_API_KEY`; bwrap, a Landlock-enforcing kernel, or macOS for confined runs). See [sandbox-acp-agent/README.md](sandbox-acp-agent/README.md). +The **sandbox variant** (`sandbox.cordis.yml`) swaps the bash executor for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local) + [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) — the one-entry executor swap the `ctx.bash` capability seam exists for) and mounts [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval) — the composition where the approval loop is LIVE: a sandbox denial escalated by the model becomes a `session/request_permission` prompt in the editor, and "Allow once" runs exactly that command under the wider mode. Run with: `pnpm run demo:sandbox-acp` (needs `DEEPSEEK_API_KEY`; bwrap, a Landlock-enforcing kernel, or macOS for confined runs). diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index e3d8542efd..60cbfe6f45 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -35,6 +35,17 @@ The editor sets each session's `cwd` to the project it opens; both the agent's b This example is the home of the harness's **snapshot tests** — they boot this server as a real subprocess, drive it with a deterministic input script, and diff its normalized output against committed golden files. The model is made deterministic by `@deepseek-ai/dsh-llm-replay`, a function/namespace plugin that installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded **session JSONL** fixture (`/session.jsonl`) — so replay needs no API key. The fixture IS the persisted session log: its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`"; use `pnpm run test:snapshot:record` when the model transcript itself should change, and `pnpm run test:snapshot:refresh` when the committed model transcript is still the right mock input and only the current replay output/goldens need to be rewritten. The two failure modes not expressible as logged chunks — a pure throw before any chunk, and cancel/hang — use an optional `/replay.override.json` sidecar (a `ReplayEntry[]` that replaces the derived script). A scenario that needs the agent to operate on existing files ships an optional `/workspace/` directory — the harness copies its contents into the temp cwd before the run (see `workspace-edit`). See [the ACP snapshot tests RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) for the full design. +## The sandbox variant (`sandbox.cordis.yml`) + +The same server with the bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) + [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) — the one-entry executor swap the `ctx.bash` capability seam exists for) plus [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/) — the composition where the approval loop is LIVE end to end: bash runs under `read-only`, a denial comes back as the structured marker, the model retries once with `sandbox_permissions` + `justification`, the ACP bridge's answerer turns that ask into a `session/request_permission` prompt in your editor, and "Allow once" runs exactly that command under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). Run it with `pnpm run demo:sandbox-acp`; Zed setup is the same as above with this command. + +- **Every approval is one-shot** (`Allow once` / `Reject` — no `allow_always`: the harness has no grant storage yet), and a dismissed prompt or a rejected ask fails closed with its own error text; so does every ask when no editor is attached to answer. +- **Two session config options are live** ([sandbox RFC § Per-session mode switching](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): a capable client shows `Sandbox` (`read-only`/`workspace-write`/`danger-full-access`) and `Approvals` (`ask`/`never`) selectors per session — a switch is one log-only event on that session's log and execution follows it; the sandbox mode is deliberately NOT stated in the prompt or narrated (the model learns the boundary from the denial marker — behavior, not belief), while an approval switch to `never` is stated and narrated; a resumed session reports its overrides back on `session/load`. +- **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). +- **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the variant loads no filesystem tools: they would bypass the bash sandbox. + +Variant tests, in this example's suites: `tests/escalation.e2e.ts` — keyless, it boots the real `sandbox.cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end; with a key and a usable runner, a scripted ACP client plays the human — the real model gets denied, escalates, the client answers `allow-once`, and the retried write must land on disk. Four scenarios in `tests/acp.snapshot.ts` run against the variant's `sandbox.cordis.snapshot.yml` replay overlay under the `sandbox` header class: the keyless `config-options` exchange, the recorded `mode-switching` arc (that class's pinned header — the approval prompt-section delta, its "changed by the user" notice, and a confined write landing under the switched mode), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file). + ## MVP limitations -The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, and `additionalDirectories` and `mcpServers` are rejected. Permission prompts (`session/request_permission`) are wired through the approval seam, but this example composes no ask-producing policy, so tools run with the executor's full authority. See `packages/ui/acp/README.md` for the full contract. +The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, and `additionalDirectories` and `mcpServers` are rejected. Permission prompts (`session/request_permission`) are wired through the approval seam; the MAIN tree composes no ask-producing policy, so its tools run with the executor's full authority — the sandbox variant above is the composition that exercises the live prompt. See `packages/ui/acp/README.md` for the full contract. diff --git a/examples/sandbox-acp-agent/cordis.snapshot.yml b/examples/acp-agent/sandbox.cordis.snapshot.yml similarity index 97% rename from examples/sandbox-acp-agent/cordis.snapshot.yml rename to examples/acp-agent/sandbox.cordis.snapshot.yml index b5f8ac73ed..04ed881b32 100644 --- a/examples/sandbox-acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/sandbox.cordis.snapshot.yml @@ -17,7 +17,7 @@ - id: base name: '@cordisjs/plugin-include' config: - path: ./cordis.yml + path: ./sandbox.cordis.yml patches: # The name is an assertion, not an override: the include skips the patch # (warning) when the id points at a different plugin, so this can never diff --git a/examples/sandbox-acp-agent/cordis.yml b/examples/acp-agent/sandbox.cordis.yml similarity index 90% rename from examples/sandbox-acp-agent/cordis.yml rename to examples/acp-agent/sandbox.cordis.yml index d02253342e..c7e0b2f9e6 100644 --- a/examples/sandbox-acp-agent/cordis.yml +++ b/examples/acp-agent/sandbox.cordis.yml @@ -1,13 +1,13 @@ -# The sandbox-acp-agent plugin tree: the sandboxed coding agent served over the -# Agent Client Protocol, with the approval seam composed — the first LIVE -# approval composition. A sandbox denial escalated by the model +# The SANDBOX VARIANT of the acp-agent example: the sandboxed coding agent +# served over the Agent Client Protocol, with the approval seam composed — +# the live approval composition (`pnpm run demo:sandbox-acp`). A sandbox denial escalated by the model # (sandbox_permissions + justification) reaches the EDITOR as a # session/request_permission prompt through the ACP bridge's answerer, and an # "Allow once" runs exactly that command under the wider mode. # # CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved # for the ACP JSON-RPC protocol (a property of @deepseek-ai/dsh-acp-agent, -# same as examples/acp-agent). +# same as the sibling cordis.yml). # # Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the # dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..ff7c4561b1 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -27,6 +27,10 @@ const AGENT = { const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) +// The sandbox variant (its own composition, not an include patch: sandboxed +// bash executor + the approval seam over the same app spine). +const SANDBOX_CONFIG = fileURLToPath(new URL('../sandbox.cordis.yml', import.meta.url)) + function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { case undefined: @@ -121,6 +125,37 @@ const SCENARIOS: Scenario[] = [ // therefore pins its own class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, + // The SANDBOX variant (sandbox.cordis.yml: sandboxed bash + approval seam). + // Replay swaps only the MODEL for the recorded transcript — every bash call + // re-executes for real under the host's actual runner (Seatbelt on macOS, + // bwrap on Linux CI), so these recordings double as cross-backend + // confinement regression; their commands are limited to `cat`/`printf` + // shapes whose bytes are identical across those backends and across + // GNU/BSD userlands. Deliberately ABSENT: a scenario whose transcript + // carries a real sandbox DENIAL — the denied command's own stderr is the + // backend's dialect (EROFS/EACCES/EPERM phrasing), so such a fixture + // replays only on the platform that recorded it; the denial→marker path + // stays on dsh-tool-bash's unit tests and the real-kernel sandbox e2e legs, + // and the escalation scenarios sidestep it by having the USER assert the + // prior denial. config-options: the session config-option surface this + // composition adds (both advertised selects, the refreshed state every + // set_config_option answers with, both rejection shapes) — protocol-only, + // replays on runner-less hosts. mode-switching: the runtime switching arc + // and NECESSARILY this class's pinned-header scenario (an approval-policy + // switch rewrites its prompt section; the resulting request/header-delta is + // legal only in the pinning scenario) — the pin commits the full sandbox + // header (persona, tool schemas WITH the escalation fields) plus the + // approval delta and its "changed by the user" notice; the SANDBOX switch + // stays deliberately silent (the visibility asymmetry), proven by BEHAVIOR. + // escalation-approved/rejected: the approval wire end-to-end under the + // default read-only/ask — the escalating call streams, + // session/request_permission attaches to it, and the scripted answer drives + // each branch (approved runs CONFINED under the granted workspace-write; + // rejected executes nothing, failing with the deterministic text). + { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, + { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, + { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, + { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, ] defineAcpSnapshotSuite({ diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts similarity index 96% rename from examples/sandbox-acp-agent/tests/escalation.e2e.ts rename to examples/acp-agent/tests/escalation.e2e.ts index 93915717a1..8b16fd3992 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -17,9 +17,9 @@ import { } from '@agentclientprotocol/sdk' /** - * examples/sandbox-acp-agent end to end. + * The sandbox variant (`sandbox.cordis.yml`) end to end. * - * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as + * Keyless smoke: boot the REAL `sandbox.cordis.yml` through the `dsh-acp-agent` bin as * an ACP subprocess and drive initialize + session/new — the real-Loader-path * guard (postmortem 0001) for THIS tree's export shapes, which now include the * sandbox executor AND the approval service. No prompt is sent, so neither the @@ -36,7 +36,7 @@ import { */ 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 configPath = fileURLToPath(new URL('../sandbox.cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The subprocess runs from a temp cwd OUTSIDE the repo; point tsx at the repo // tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). @@ -116,7 +116,7 @@ afterEach(async () => { workdir = undefined }) -describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () => { +describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) spawned = spawnSandboxAcpAgent(workdir, 'reject-once') @@ -161,7 +161,7 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () }, 30_000) }) -describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox-acp-agent e2e: the live approval loop', () => { +describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) spawned = spawnSandboxAcpAgent(workdir, 'allow-once') diff --git a/examples/sandbox-acp-agent/tests/snapshots/config-options/input.json b/examples/acp-agent/tests/snapshots/config-options/input.json similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/config-options/input.json rename to examples/acp-agent/tests/snapshots/config-options/input.json diff --git a/examples/sandbox-acp-agent/tests/snapshots/config-options/session.jsonl b/examples/acp-agent/tests/snapshots/config-options/session.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/config-options/session.jsonl rename to examples/acp-agent/tests/snapshots/config-options/session.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/config-options/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/input.json b/examples/acp-agent/tests/snapshots/escalation-approved/input.json similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-approved/input.json rename to examples/acp-agent/tests/snapshots/escalation-approved/input.json diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl rename to examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/input.json b/examples/acp-agent/tests/snapshots/escalation-rejected/input.json similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/input.json rename to examples/acp-agent/tests/snapshots/escalation-rejected/input.json diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl rename to examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/input.json b/examples/acp-agent/tests/snapshots/mode-switching/input.json similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/mode-switching/input.json rename to examples/acp-agent/tests/snapshots/mode-switching/input.json diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl b/examples/acp-agent/tests/snapshots/mode-switching/session.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl rename to examples/acp-agent/tests/snapshots/mode-switching/session.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md diff --git a/examples/sandbox-acp-agent/tests/snapshots/mode-switching/workspace/notes.txt b/examples/acp-agent/tests/snapshots/mode-switching/workspace/notes.txt similarity index 100% rename from examples/sandbox-acp-agent/tests/snapshots/mode-switching/workspace/notes.txt rename to examples/acp-agent/tests/snapshots/mode-switching/workspace/notes.txt diff --git a/examples/sandbox-acp-agent/README.md b/examples/sandbox-acp-agent/README.md deleted file mode 100644 index bada288bde..0000000000 --- a/examples/sandbox-acp-agent/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# sandbox-acp-agent - -The coding agent with its bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) + [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) — the one-entry executor swap the `ctx.bash` capability seam exists for), served over the **Agent Client Protocol**, plus [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/) — which makes this the first composition where the approval loop is LIVE end to end: bash runs under `read-only`, a denial comes back as the structured marker, the model retries once with `sandbox_permissions` + `justification`, the ACP bridge's answerer turns that ask into a `session/request_permission` prompt in your editor, and "Allow once" runs exactly that command under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). - -```sh -pnpm run demo:sandbox-acp # needs DEEPSEEK_API_KEY; drive it from Zed or any ACP client -``` - -Zed setup is the same as [acp-agent](../acp-agent/README.md) with this example's command; only the leaf `cordis.yml` differs (the sandbox stack + the approval entry in place of the local bash executor and the extra tool stacks). - -- **Every approval is one-shot** (`Allow once` / `Reject` — no `allow_always`: the harness has no grant storage yet), and a dismissed prompt or a rejected ask fails closed with its own error text; so does every ask when no editor is attached to answer. -- **Two session config options are live** ([sandbox RFC § Per-session mode switching](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): a capable client shows `Sandbox` (`read-only`/`workspace-write`/`danger-full-access`) and `Approvals` (`ask`/`never`) selectors per session — a switch is one log-only event on that session's log and execution follows it; the sandbox mode is deliberately NOT stated in the prompt or narrated (the model learns the boundary from the denial marker — behavior, not belief), while an approval switch to `never` is stated and narrated; a resumed session reports its overrides back on `session/load`. -- **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -- **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the filesystem tools stay unloaded for the same reason as `sandbox-agent`: they would bypass the bash sandbox. - -Tests: `tests/escalation.e2e.ts` — keyless, it boots the real `cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end (both advertised with composition currents, switches honored and echoed as complete state, out-of-vocabulary values rejected); with a key and a usable runner, a scripted ACP client plays the human — the real model gets denied, escalates, the client answers `allow-once`, and the retried write must land on disk. `tests/acp.snapshot.ts` (the [shared snapshot kit](../../packages/support/acp-snapshot/) over this composition's `cordis.snapshot.yml` replay overlay) pins four scenarios as committed wire bytes: the keyless config-option exchange, the recorded `mode-switching` arc (the suite's pinned header — both switches, their prompt-section deltas, one "changed by the user" notice per knob, and a confined write landing under the switched mode), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap (or build the Landlock launcher) first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file). diff --git a/examples/sandbox-acp-agent/package.json b/examples/sandbox-acp-agent/package.json deleted file mode 100644 index 5a4c9a989e..0000000000 --- a/examples/sandbox-acp-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "sandbox-acp-agent-example", - "description": "Runnable demo: the sandboxed coding agent as an ACP server, with sandbox-escalation approval prompts answered by the editor", - "private": true, - "version": "0.0.1", - "type": "module" -} diff --git a/examples/sandbox-acp-agent/tests/acp.snapshot.ts b/examples/sandbox-acp-agent/tests/acp.snapshot.ts deleted file mode 100644 index 418b8068d3..0000000000 --- a/examples/sandbox-acp-agent/tests/acp.snapshot.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' - -/** - * Snapshot suite for the SANDBOXED composition (`../cordis.yml`, swapped to - * the sibling `cordis.snapshot.yml` replay overlay by the bin under - * `DSH_SNAPSHOT=replay`). Replay swaps only the MODEL for the recorded - * transcript — every bash call re-executes for real under the host's actual - * runner (Seatbelt on macOS, bwrap on Linux CI: ci.yml's snapshot lane - * installs bubblewrap for exactly this), so the recorded scenarios double as - * cross-backend confinement regression: an allowed command a runner change - * starts denying fails replay outright. Their commands are limited to - * `cat`/`printf` shapes whose bytes are identical across those backends and - * across GNU/BSD userlands. - * - * Deliberately ABSENT: a scenario whose transcript carries a real sandbox - * DENIAL. The harness-authored `[sandbox: file access denied …]` marker is - * byte-stable, but the denied command's own stderr is the backend's dialect - * (bwrap EROFS "Read-only file system", Landlock EACCES "Permission - * denied", Seatbelt EPERM "Operation not permitted", GNU vs BSD phrasing on - * top), and stderr reaches both compared surfaces — such a fixture replays - * only on the platform that recorded it. The denial→marker path stays on - * dsh-tool-bash's unit tests and the real-kernel sandbox e2e legs - * (.github/workflows/sandbox.yml); the escalation scenarios below sidestep - * it by having the USER assert the prior denial, so the recorded model - * escalates without a platform-variant denial in the log. - */ -const SCENARIOS: Scenario[] = [ - // Protocol-only (keyless, authored): the session config-option surface - // this composition adds — both advertised selects on session/new, the - // complete refreshed state every session/set_config_option answers with, - // and both rejection shapes — as committed wire bytes. No bash runs, so - // this one still replays on runner-less hosts. - { name: 'config-options', hasModelTurn: false, recorded: false }, - // The runtime mode-switching arc, and NECESSARILY the pinned-header - // scenario: an approval-policy switch rewrites its prompt section, and the - // resulting request/header-delta is legal only in the pinning scenario - // (the factory's uniformity guard). The pin commits this composition's - // full header — persona, tool schemas WITH the escalation fields — plus - // the approval delta and its "changed by the user" notice verbatim. The - // SANDBOX switch is deliberately silent (no section, no notice — the - // sandbox RFC's visibility asymmetry): the recorded arc proves it by - // BEHAVIOR, a confined write landing under the switched mode with no - // header change. - { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1 }, - // The approval wire end-to-end, under the DEFAULT read-only/ask (a switch - // would emit a header-delta the uniformity guard forbids here): the - // escalating bash call streams, session/request_permission attaches to it - // (allow-once / reject-once), and the scripted answer drives each branch — - // an approved run executes CONFINED under the granted workspace-write; a - // rejected one executes nothing and fails with the deterministic - // rejection text. - { name: 'escalation-approved', hasModelTurn: true, recorded: true }, - { name: 'escalation-rejected', hasModelTurn: true, recorded: true }, -] - -function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { - switch (value) { - case undefined: - case '': - case 'replay': - return 'replay' - case 'record': - return 'record' - case 'refresh': - return 'refresh' - default: - throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`) - } -} - -defineAcpSnapshotSuite({ - agent: { - binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), - tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), - }, - snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'), - scenarios: SCENARIOS, - mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT), -}) diff --git a/package.json b/package.json index ed0b603feb..4d9ad427a4 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", - "demo:sandbox-acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/sandbox-acp-agent/cordis.yml", + "demo:sandbox-acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/sandbox.cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/bash/README.md b/packages/bash/README.md index 4072998f3e..9b1bb79cad 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -9,4 +9,4 @@ The canonical three-package capability seam (see [capability seams](../../docs/r | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)). +The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's `sandbox.cordis.yml`](../../examples/acp-agent/)). diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 2a5ae63b8e..c6b04e2206 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -30,4 +30,4 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego workspaceRoot: !!js process.cwd() ``` -The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo. +The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's sandbox variant](../../../examples/acp-agent/) for the runnable demo. diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 9e104192d1..cab1bcebb2 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -9,4 +9,4 @@ The confinement half of the [capability-seam split](../../docs/rfc/implemented/a The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase). +Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's sandbox variant](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase). diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index abf7511438..edcd55c6e7 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -15,4 +15,4 @@ Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2 name: '@deepseek-ai/dsh-sandbox-local' ``` -Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable composition. +Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example's sandbox variant (`sandbox.cordis.yml`)](../../../examples/acp-agent/) for the runnable composition. From 95635dfa66669fd883d6fd7dc508d447eeb8d9f5 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 12 Jul 2026 21:03:41 +0800 Subject: [PATCH 28/31] =?UTF-8?q?feat(permission):=20user-facing=20permiss?= =?UTF-8?q?ion=20presets=20=E2=80=94=20one=20Permissions=20select=20over?= =?UTF-8?q?=20the=20two=20knobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset names a bundle of the two mechanism knobs — request = workspace-write + ask, yolo = danger-full-access + never — so the editor shows ONE 'Permissions' select where the sandbox-mode and approval-policy tiers stay orthogonal capabilities (the Codex /approvals shape: presets over two dials). ctx.permission (dsh-permission) owns the config-defined table, validates the default preset's bundle against the composed knob defaults at load (fails loud), and writes a switch THROUGH: one log-only permission/preset event (the audit fact reverse-mapping cannot recover — the planned 'agent' preset shares request's knob values and differs only in composed policy) plus each knob event via its own setter, deduped — a net-zero switch appends nothing. Every knob consumer keeps reading its own fold, untouched. The current preset DERIVES from the effective knob values — the fold breaks bundle ties, a knob state outside the table is the reserved 'custom' value (a state, not an error: shown while it holds, switchable FROM, never a target), and defaultPreset disappears (zero-event state reverse-maps from the composition defaults). The ACP bridge drops the two per-knob selects for the one preset select (advertised only when ctx.permission is composed); pending/anchor/no-op semantics carry over unchanged, with the no-op echo acknowledged before vocabulary validation so a client re-pushing a derived 'custom' current never errors. The sandbox variant example composes the service with a workspace-write default; the permission-switching, escalation-approved and escalation-rejected scenarios are re-recorded under it (escalations now target an outside-workspace /tmp path under danger-full-access, self-cleaning) and config-options is re-authored on the single-select wire. --- docs/capability-seams.md | 5 + docs/config-catalog.md | 37 +- docs/cordis-catalog/services.md | 15 + docs/module-graph.md | 9 +- docs/persistence-catalog.md | 12 + .../implemented/feature/2026-07-06-sandbox.md | 6 +- examples/acp-agent/README.md | 4 +- examples/acp-agent/sandbox.cordis.yml | 14 +- examples/acp-agent/tests/acp.snapshot.ts | 28 +- examples/acp-agent/tests/escalation.e2e.ts | 51 ++- .../tests/snapshots/config-options/input.json | 32 +- .../config-options/stdout.golden.jsonl | 8 +- .../snapshots/escalation-approved/input.json | 17 +- .../escalation-approved/session.jsonl | 334 +++++++------- .../escalation-approved/stdout.golden.jsonl | 64 ++- .../snapshots/escalation-rejected/input.json | 17 +- .../escalation-rejected/session.jsonl | 414 +++++++++--------- .../escalation-rejected/stdout.golden.jsonl | 117 +++-- .../tests/snapshots/mode-switching/input.json | 11 - .../snapshots/mode-switching/session.jsonl | 245 ----------- .../snapshots/permission-switching/input.json | 27 ++ .../permission-switching/session.jsonl | 235 ++++++++++ .../stdout.golden.jsonl | 176 ++++---- .../system-prompt.golden.md | 0 .../workspace/notes.txt | 0 .../cordis/tool-cordis/src/api-catalog.ts | 22 + packages/ui/README.md | 1 + packages/ui/acp/package.json | 8 +- packages/ui/acp/src/index.ts | 124 +++--- packages/ui/acp/tests/config-options.spec.ts | 250 +++++------ packages/ui/acp/tsconfig.json | 3 + packages/ui/permission/README.md | 7 + packages/ui/permission/package.json | 41 ++ packages/ui/permission/src/index.ts | 236 ++++++++++ .../ui/permission/tests/permission.spec.ts | 147 +++++++ packages/ui/permission/tsconfig.json | 33 ++ pnpm-lock.yaml | 25 ++ scripts/gen-doc-graphs.ts | 9 + tsconfig.build.json | 1 + tsconfig.json | 1 + 40 files changed, 1724 insertions(+), 1062 deletions(-) delete mode 100644 examples/acp-agent/tests/snapshots/mode-switching/input.json delete mode 100644 examples/acp-agent/tests/snapshots/mode-switching/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/permission-switching/input.json create mode 100644 examples/acp-agent/tests/snapshots/permission-switching/session.jsonl rename examples/acp-agent/tests/snapshots/{mode-switching => permission-switching}/stdout.golden.jsonl (75%) rename examples/acp-agent/tests/snapshots/{mode-switching => permission-switching}/system-prompt.golden.md (100%) rename examples/acp-agent/tests/snapshots/{mode-switching => permission-switching}/workspace/notes.txt (100%) create mode 100644 packages/ui/permission/README.md create mode 100644 packages/ui/permission/package.json create mode 100644 packages/ui/permission/src/index.ts create mode 100644 packages/ui/permission/tests/permission.spec.ts create mode 100644 packages/ui/permission/tsconfig.json diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a6c9c766aa..196d9ba17a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -56,6 +56,8 @@ flowchart LR pkg_sandbox_local["sandbox-local"] pkg_approval["approval"] svc_approval["ctx.approval
Approval seam"] + pkg_permission["permission"] + svc_permission["ctx.permission
Permission presets"] pkg_code_runtime["code-runtime"] svc_codeRuntime["ctx.codeRuntime
Code-execution seam"] pkg_code_runtime_worker["code-runtime-worker"] @@ -99,6 +101,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_permission --> svc_permission pkg_sandbox --> svc_sandbox pkg_sandbox_local --> svc_sandbox pkg_session --> svc_sessions @@ -139,6 +142,7 @@ flowchart LR svc_fs --> pkg_tool_fs svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic + svc_permission --> pkg_acp svc_sandbox --> pkg_bash_sandbox svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop @@ -185,6 +189,7 @@ flowchart LR | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`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 replace bash-local without touching them. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | +| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (request/yolo) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e8bd9e508..76c347266d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:246`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -408,6 +408,41 @@ export interface Config { Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-permission` + +Requires: `bash` · `approval` + +```ts config-catalog +/** The {@link PermissionService} config: the deployment's preset table. */ +export interface Config { + /** + * The preset table: name → knob bundle. Defaults to `request` + * (workspace-write + ask) and `yolo` (danger-full-access + never). The + * name `custom` is reserved for the derived not-a-preset state. + */ + presets?: Record +} + +/** + * One preset's knob bundle — the sandbox mode and approval policy a session + * runs under while the preset is active — plus its presentation. + */ +export interface PresetSpec { + /** The `bash/sandbox-mode` value the preset writes through. */ + sandbox: SandboxMode + /** The `approval/policy` value the preset writes through. */ + approval: ApprovalPolicy + /** The display label a client shows for this preset; the raw table key when omitted. */ + name?: string + /** One user-facing sentence on what the preset means; omitted when not configured. */ + description?: string +} +``` + +Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) + +Source: [`packages/ui/permission/src/index.ts:97`](../packages/ui/permission/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf41cfdd5f..e4f6e278aa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -161,6 +161,21 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts) +## `ctx.permission` — `PermissionService` + +The permission service (`ctx.permission`). Owns the deployment's preset table and THE write path for preset switches; presentation layers (the ACP bridge's single `Permissions` select) advertise names and call set. Composing it REQUIRES both mechanism knobs — a confining `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no table entry is not an error but the derived CUSTOM_PRESET state: shown as the current value, never a switch target. + +```ts cordis-catalog +current(events: readonly SessionEvent[]): string +resolve(name: string): PresetSpec +optionOf(name: string): PresetOption +set(session: Session, name: string): void +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/ui/permission/src/index.ts:115`](../../packages/ui/permission/src/index.ts) + ## `ctx.sandbox` — `SandboxProvider` (abstract seam) Abstract process-sandbox service. Subclass, implement confine, and load the subclass as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). diff --git a/docs/module-graph.md b/docs/module-graph.md index 2acee6ffc5..d6e5b575ba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -92,6 +92,7 @@ flowchart TD pkg_acp["acp"] pkg_acp_agent["acp-agent"] pkg_app_boot["app-boot"] + pkg_permission["permission"] pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] @@ -187,6 +188,10 @@ flowchart TD pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox + pkg_permission --> pkg_bash + pkg_permission --> pkg_sandbox + pkg_permission --> pkg_session + pkg_permission --> pkg_user_approval pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -234,6 +239,7 @@ flowchart TD pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm + pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox pkg_acp --> pkg_session pkg_acp --> pkg_session_persistence @@ -357,6 +363,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`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), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`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) | @@ -367,7 +374,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`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), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 245d25fa5f..afa07bb89d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -153,6 +153,18 @@ A hook command's outcome — log-only, paired with a prior `hook/invoked` (same Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook-protocol/src/types.ts) +### `permission/*` + +#### `permission/preset` — log-only + +The session's permission preset was switched — log-only (the `bash/sandbox-mode` precedent): durable and replayable, never in the model transcript. The LAST such event is the session's preset (effectivePermissionPreset); the knob events the switch wrote through follow it in the same turn, and they — not this record of the user's choice — are what execution reads. + +```ts persistence-catalog +'permission/preset': { preset: string } +``` + +Source: [`packages/ui/permission/src/index.ts:42`](../packages/ui/permission/src/index.ts) + ### `prompt/*` #### `prompt/blocked` — log-only diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 366e0adada..4275ab9484 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -34,7 +34,7 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. -What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: a `sandbox-mode` and an `approval-policy` config-option select per session (each advertised only when its knob is composable), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. +What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: one `Permissions` config-option select per session (advertised when the `dsh-permission` preset layer is composed; each preset bundles a sandbox mode and an approval policy and writes through to both knob events — a knob state outside the table derives a switch-away-only `custom` current), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier): @@ -57,7 +57,7 @@ Reject instead and nothing executes: the result is the verbatim `the user reject - Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change. - `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet. - `dsh-bash`'s request/spec split (`BashExecRequest` → `resolve()` → `BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge. -- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the sandbox example suite's `mode-switching` fixture. +- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the acp example suite's `permission-switching` fixture. #### The seam: `ctx.sandbox` @@ -135,7 +135,7 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. - Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (the acp-agent example's `escalation.e2e.ts`): the real `sandbox.cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. - With-key e2e (`examples/acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. +- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded permission-switching arc as the pinned header of its class — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing one `request`→`yolo` preset switch (the `permission/preset` event written through to both knobs), the approval prompt-section delta and its "changed by the user" notice; and both recorded escalation branches over scripted `permissionAnswers` (grant runs under the granted `danger-full-access`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. ## Deferred phases diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 60cbfe6f45..31565a7b2e 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -40,11 +40,11 @@ This example is the home of the harness's **snapshot tests** — they boot this The same server with the bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) + [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) — the one-entry executor swap the `ctx.bash` capability seam exists for) plus [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/) — the composition where the approval loop is LIVE end to end: bash runs under `read-only`, a denial comes back as the structured marker, the model retries once with `sandbox_permissions` + `justification`, the ACP bridge's answerer turns that ask into a `session/request_permission` prompt in your editor, and "Allow once" runs exactly that command under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). Run it with `pnpm run demo:sandbox-acp`; Zed setup is the same as above with this command. - **Every approval is one-shot** (`Allow once` / `Reject` — no `allow_always`: the harness has no grant storage yet), and a dismissed prompt or a rejected ask fails closed with its own error text; so does every ask when no editor is attached to answer. -- **Two session config options are live** ([sandbox RFC § Per-session mode switching](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): a capable client shows `Sandbox` (`read-only`/`workspace-write`/`danger-full-access`) and `Approvals` (`ask`/`never`) selectors per session — a switch is one log-only event on that session's log and execution follows it; the sandbox mode is deliberately NOT stated in the prompt or narrated (the model learns the boundary from the denial marker — behavior, not belief), while an approval switch to `never` is stated and narrated; a resumed session reports its overrides back on `session/load`. +- **One session config option is live** ([sandbox RFC § Per-session mode switching](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): a capable client shows a `Permissions` select per session (`request` = workspace-write + ask, `yolo` = danger-full-access + never — the `@deepseek-ai/dsh-permission` preset table), and a switch is one log-only `permission/preset` event written through to the two knob events, execution following the knobs; the sandbox mode is deliberately NOT stated in the prompt or narrated (the model learns the boundary from the denial marker — behavior, not belief), while an approval switch to `never` is stated and narrated; a resumed session reports its overrides back on `session/load`. - **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). - **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the variant loads no filesystem tools: they would bypass the bash sandbox. -Variant tests, in this example's suites: `tests/escalation.e2e.ts` — keyless, it boots the real `sandbox.cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end; with a key and a usable runner, a scripted ACP client plays the human — the real model gets denied, escalates, the client answers `allow-once`, and the retried write must land on disk. Four scenarios in `tests/acp.snapshot.ts` run against the variant's `sandbox.cordis.snapshot.yml` replay overlay under the `sandbox` header class: the keyless `config-options` exchange, the recorded `mode-switching` arc (that class's pinned header — the approval prompt-section delta, its "changed by the user" notice, and a confined write landing under the switched mode), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file). +Variant tests, in this example's suites: `tests/escalation.e2e.ts` — keyless, it boots the real `sandbox.cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end; with a key and a usable runner, a scripted ACP client plays the human — the real model escalates a user-asserted denial, the client answers `allow-once`, and the retried write must land on disk. Four scenarios in `tests/acp.snapshot.ts` run against the variant's `sandbox.cordis.snapshot.yml` replay overlay under the `sandbox` header class: the keyless `config-options` exchange, the recorded `permission-switching` arc (that class's pinned header — one `request`→`yolo` preset switch, its approval prompt-section delta and "changed by the user" notice), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file). ## MVP limitations diff --git a/examples/acp-agent/sandbox.cordis.yml b/examples/acp-agent/sandbox.cordis.yml index c7e0b2f9e6..f83e5cce3f 100644 --- a/examples/acp-agent/sandbox.cordis.yml +++ b/examples/acp-agent/sandbox.cordis.yml @@ -23,8 +23,8 @@ # The sandbox stack: the platform-runner provider (bwrap → per-platform # Landlock launcher → Seatbelt, functionally probed), then the confined bash executor. -# read-only is the fail-safe default; the write boundary for an escalated -# workspace-write run is workspaceRoot + the platform's temp area. NOTE: the +# workspace-write is the default (the 'request' preset's bundle); the write +# boundary is workspaceRoot + the platform's temp area. NOTE: the # workspace root is CONFIG-FIXED for the executor's lifetime (the launch dir # here), while each ACP session has its own cwd — a per-session root is config- # phase future work in the sandbox RFC. @@ -33,7 +33,7 @@ - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: - mode: read-only + mode: workspace-write workspaceRoot: !!js process.cwd() # The approval seam (ctx.approval — mechanism only, no config): with it @@ -44,6 +44,14 @@ - id: approval name: '@deepseek-ai/dsh-user-approval' +# The user-facing permission presets: ONE `Permissions` select in the editor +# (request/yolo), each bundling a sandbox mode and an approval policy, written +# through to the two knob events. The defaults above (workspace-write + ask) +# reverse-map to `request`, so a fresh session starts there; a knob state +# outside the table derives the switch-away-only `custom` value. +- id: permission + name: '@deepseek-ai/dsh-permission' + # The ACP server app: the agent-core spine + JSONL persistence + the ACP # bridge (whose approval answerer completes the loop). - id: acp-agent diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index ff7c4561b1..7438cedeeb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -140,20 +140,24 @@ const SCENARIOS: Scenario[] = [ // prior denial. config-options: the session config-option surface this // composition adds (both advertised selects, the refreshed state every // set_config_option answers with, both rejection shapes) — protocol-only, - // replays on runner-less hosts. mode-switching: the runtime switching arc - // and NECESSARILY this class's pinned-header scenario (an approval-policy - // switch rewrites its prompt section; the resulting request/header-delta is - // legal only in the pinning scenario) — the pin commits the full sandbox - // header (persona, tool schemas WITH the escalation fields) plus the - // approval delta and its "changed by the user" notice; the SANDBOX switch - // stays deliberately silent (the visibility asymmetry), proven by BEHAVIOR. + // replays on runner-less hosts. permission-switching: the runtime + // preset-switch arc (request → yolo: one permission/preset event written + // through to both knobs) and NECESSARILY this class's pinned-header + // scenario (yolo's approval=never rewrites the prompt section; the + // resulting request/header-delta is legal only in the pinning scenario) — + // the pin commits the full sandbox header (persona, tool schemas WITH the + // escalation fields) plus the approval delta and its "changed by the user" + // notice; the sandbox half of the bundle stays deliberately silent (the + // visibility asymmetry) and is pinned as its stamped knob event. // escalation-approved/rejected: the approval wire end-to-end under the - // default read-only/ask — the escalating call streams, - // session/request_permission attaches to it, and the scripted answer drives - // each branch (approved runs CONFINED under the granted workspace-write; - // rejected executes nothing, failing with the deterministic text). + // default request preset (workspace-write/ask) — the escalating call + // (danger-full-access for a user-asserted outside-workspace denial) + // streams, session/request_permission attaches to it, and the scripted + // answer drives each branch (approved runs under the granted mode and + // self-cleans its /tmp target; rejected executes nothing, failing with + // the deterministic text). { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, - { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, + { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, ] diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 8b16fd3992..9c99f53306 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -26,13 +26,13 @@ import { * model nor a sandbox runner is ever exercised. * * With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable - * platform runner): a scripted ACP client plays the human. The real model is - * denied under `read-only`, escalates with `sandbox_permissions` + + * platform runner): a scripted ACP client plays the human. The prompt asserts + * a prior denial (the organic denial→marker path lives on the sandbox e2e + * legs and unit tiers), the real model escalates with `sandbox_permissions` + * `justification`, the bridge prompts THIS client over * `session/request_permission`, the client answers `allow-once`, and the - * retried write must land ON DISK (world-verified). The session cwd is a temp - * dir under the platform temp area, which `workspace-write` grants — so either - * escalation target the model picks can land the write. + * retried write must land ON DISK (world-verified) — under the granted mode, + * a temp-dir session cwd is writable either way. */ const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) @@ -130,34 +130,33 @@ describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader) expect(sessionId.length).toBeGreaterThan(0) }, 30_000) - it('advertises both session config options and honors a switch end to end (no key, no model)', async () => { + it('advertises the Permissions select and honors a switch end to end (no key, no model)', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) spawned = spawnSandboxAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // This tree composes bash-sandbox (mode: read-only) + approval → both - // knobs advertise, currents from composition config. + // This tree composes the permission presets over bash-sandbox + approval → + // ONE select advertises, current from the configured default preset. const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) - .toEqual([['sandbox-mode', 'read-only'], ['approval-policy', 'ask']]) + .toEqual([['permission', 'request']]) // A switch responds with the COMPLETE refreshed state (the spec contract), - // and the new currents survive in the response of a second switch. - const afterSandbox = await client.setSessionConfigOption({ - sessionId: created.sessionId, configId: 'sandbox-mode', value: 'workspace-write', + // and the new current survives in the response of a second switch. + const afterYolo = await client.setSessionConfigOption({ + sessionId: created.sessionId, configId: 'permission', value: 'yolo', }) - const afterApproval = await client.setSessionConfigOption({ - sessionId: created.sessionId, configId: 'approval-policy', value: 'never', + expect(afterYolo.configOptions?.find(option => option.id === 'permission')) + .toMatchObject({ currentValue: 'yolo' }) + const again = await client.setSessionConfigOption({ + sessionId: created.sessionId, configId: 'permission', value: 'yolo', }) - const currents = (afterApproval.configOptions ?? []).map(option => - [option.id, 'currentValue' in option ? option.currentValue : undefined]) - expect(afterSandbox.configOptions?.find(option => option.id === 'sandbox-mode')) - .toMatchObject({ currentValue: 'workspace-write' }) - expect(currents).toEqual([['sandbox-mode', 'workspace-write'], ['approval-policy', 'never']]) + expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) + .toEqual([['permission', 'yolo']]) // An out-of-vocabulary value is a protocol error, never a silent default. await expect(client.setSessionConfigOption({ - sessionId: created.sessionId, configId: 'sandbox-mode', value: 'yolo', - })).rejects.toThrow(/unknown sandbox-mode value/) + sessionId: created.sessionId, configId: 'permission', value: 'plan', + })).rejects.toThrow(/unknown permission value/) }, 30_000) }) @@ -171,12 +170,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2 const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `Use the bash tool to create the file ${workdir}/escalated.txt containing exactly "ACP_ESCALATION_OK". ` - + 'If the sandbox denies it, retry once with sandbox_permissions and a one-sentence justification, then stop.' }], + prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": ` + + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // The WORLD: the approved escalated retry landed the write read-only denied. + // The WORLD: the approved escalated retry landed the write. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') expect(proof).toContain('ACP_ESCALATION_OK') @@ -200,8 +199,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2 const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) const res = await client.prompt({ sessionId, - prompt: [{ type: 'text', text: `Use the bash tool to create the file ${workdir}/refused.txt containing "NO". ` - + 'If the sandbox denies it, retry once with sandbox_permissions and a one-sentence justification. If that is rejected, stop and say so.' }], + prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": ` + + 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }], }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) diff --git a/examples/acp-agent/tests/snapshots/config-options/input.json b/examples/acp-agent/tests/snapshots/config-options/input.json index 043019b659..f18af9e7e9 100644 --- a/examples/acp-agent/tests/snapshots/config-options/input.json +++ b/examples/acp-agent/tests/snapshots/config-options/input.json @@ -1,10 +1,30 @@ { "steps": [ - { "op": "initialize" }, - { "op": "newSession" }, - { "op": "setConfigOption", "configId": "sandbox-mode", "value": "workspace-write" }, - { "op": "setConfigOption", "configId": "approval-policy", "value": "never" }, - { "op": "setConfigOptionExpectError", "configId": "sandbox-mode", "value": "yolo" }, - { "op": "setConfigOptionExpectError", "configId": "reasoning-effort", "value": "max" } + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "yolo" + }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "request" + }, + { + "op": "setConfigOptionExpectError", + "configId": "permission", + "value": "plan" + }, + { + "op": "setConfigOptionExpectError", + "configId": "reasoning-effort", + "value": "max" + } ] } diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 3fab1e81fa..f68a1a2165 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"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}}","configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"never","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} -{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown sandbox-mode value \"yolo\""}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"yolo","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/input.json b/examples/acp-agent/tests/snapshots/escalation-approved/input.json index 4cfa610f55..226171ecfc 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/input.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/input.json @@ -1,10 +1,19 @@ { "steps": [ - { "op": "initialize" }, - { "op": "newSession" }, - { "op": "prompt", "text": "The sandbox already denied writing escalated.txt in this workspace earlier. Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > escalated.txt && cat escalated.txt, with sandbox_permissions set to workspace-write and the justification 'the user asked to write escalated.txt in the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop." } + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop." + } ], "permissionAnswers": [ - { "kind": "allow_once" } + { + "kind": "allow_once" + } ] } diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 2ac9d27044..0070d9d43f 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,149 +1,185 @@ -{"type":"session","version":0,"id":"8f399407-f505-45e5-9058-0994ff7b0865","createdAt":1783486769425,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-xKPr5d"} -{"type":"turn/start","seq":0,"time":1783486769426,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783486769426,"data":{"content":[{"type":"text","text":"The sandbox already denied writing escalated.txt in this workspace earlier. Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > escalated.txt && cat escalated.txt, with sandbox_permissions set to workspace-write and the justification 'the user asked to write escalated.txt in the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783486769427,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783486769427,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783486770051,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783486770052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783486770179,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783486770212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783486770213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783486770214,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783486770214,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} -{"type":"assistant/chunk","seq":11,"time":1783486770215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} -{"type":"assistant/chunk","seq":12,"time":1783486770215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":1783486770242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":14,"time":1783486770243,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":15,"time":1783486770277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":16,"time":1783486770278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":17,"time":1783486770278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":18,"time":1783486770306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":19,"time":1783486770307,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":20,"time":1783486770335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":21,"time":1783486770337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} -{"type":"assistant/chunk","seq":22,"time":1783486770338,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-w"}}} -{"type":"assistant/chunk","seq":23,"time":1783486770338,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"rite"}}} -{"type":"assistant/chunk","seq":24,"time":1783486770338,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":25,"time":1783486770368,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":26,"time":1783486770369,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":27,"time":1783486770430,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":28,"time":1783486770431,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} -{"type":"assistant/chunk","seq":29,"time":1783486770431,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":30,"time":1783486770458,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approve"}}} -{"type":"assistant/chunk","seq":31,"time":1783486770459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":32,"time":1783486770459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" permission"}}} -{"type":"assistant/chunk","seq":33,"time":1783486770474,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} -{"type":"assistant/chunk","seq":34,"time":1783486770474,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":35,"time":1783486770475,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":36,"time":1783486770475,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":37,"time":1783486770475,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" proceed"}}} -{"type":"assistant/chunk","seq":38,"time":1783486770518,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":39,"time":1783486770606,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":40,"time":1783486770606,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":41,"time":1783486770606,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":42,"time":1783486770606,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783486770634,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":44,"time":1783486770634,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783486770634,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":46,"time":1783486770634,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783486770682,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":48,"time":1783486770683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":49,"time":1783486770683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":50,"time":1783486770683,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":51,"time":1783486770694,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":52,"time":1783486770694,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":53,"time":1783486770694,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":54,"time":1783486770695,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":55,"time":1783486770697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":56,"time":1783486770697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":57,"time":1783486770740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":58,"time":1783486770740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":59,"time":1783486770740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":60,"time":1783486770740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":61,"time":1783486770740,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1783486770802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":63,"time":1783486770802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":64,"time":1783486770802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":65,"time":1783486770802,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":66,"time":1783486770803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":67,"time":1783486770820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":68,"time":1783486770821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":69,"time":1783486770821,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":70,"time":1783486770909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":71,"time":1783486770910,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":72,"time":1783486770910,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":73,"time":1783486770910,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" its"}}} -{"type":"assistant/chunk","seq":74,"time":1783486770924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" content"}}} -{"type":"assistant/chunk","seq":75,"time":1783486770925,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1783486770953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":77,"time":1783486770953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":78,"time":1783486770953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":79,"time":1783486770953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":80,"time":1783486770981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":81,"time":1783486770983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":82,"time":1783486770983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":83,"time":1783486770983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":84,"time":1783486771018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":85,"time":1783486771018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"works"}}} -{"type":"assistant/chunk","seq":86,"time":1783486771018,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"pace"}}} -{"type":"assistant/chunk","seq":87,"time":1783486771019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"-w"}}} -{"type":"assistant/chunk","seq":88,"time":1783486771019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"rite"}}} -{"type":"assistant/chunk","seq":89,"time":1783486771050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":90,"time":1783486771083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":91,"time":1783486771083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":92,"time":1783486771083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":93,"time":1783486771083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":94,"time":1783486771083,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":95,"time":1783486771084,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":96,"time":1783486771113,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":97,"time":1783486771114,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":98,"time":1783486771114,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":99,"time":1783486771114,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":100,"time":1783486771142,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":101,"time":1783486771142,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":102,"time":1783486771142,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":103,"time":1783486771142,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":104,"time":1783486771142,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":105,"time":1783486771216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":106,"time":1783486771217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":107,"time":1783486771217,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783486771219,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":109,"time":1783486771231,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to retry the command with sandbox_permissions set to workspace-write. They explicitly said they will approve the permission prompt. Let me proceed."}}}} -{"type":"assistant/chunk","seq":110,"time":1783486771232,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt and cat its content\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}}}} -{"type":"assistant/chunk","seq":111,"time":1783486771232,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":160,"cacheReadTokens":0,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":112,"time":1783486771232,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":113,"time":1783486771236,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to retry the command with sandbox_permissions set to workspace-write. They explicitly said they will approve the permission prompt. Let me proceed."},{"type":"tool-call","id":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt and cat its content\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}],"usage":{"inputTokens":1255,"outputTokens":160,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} -{"type":"tool/call","seq":114,"time":1783486771236,"data":{"turn":1,"step":1,"callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt and cat its content\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}} -{"type":"approval/asked","seq":115,"time":1783486771238,"data":{"id":"3ec45405-5add-4929-a755-e8c077ec7a7e","toolName":"bash","callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} -{"type":"approval/decided","seq":116,"time":1783486771243,"data":{"id":"3ec45405-5add-4929-a755-e8c077ec7a7e","outcome":"allowed-once"}} -{"type":"tool/result","seq":117,"time":1783486771442,"data":{"turn":1,"step":1,"callId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[114],"surfaceOp":"append"} -{"type":"step/end","seq":118,"time":1783486771443,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":119,"time":1783486771443,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":120,"time":1783486771965,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":121,"time":1783486771965,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":122,"time":1783486772052,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":123,"time":1783486772113,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} -{"type":"assistant/chunk","seq":124,"time":1783486772113,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":125,"time":1783486772113,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":126,"time":1783486772114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":127,"time":1783486772115,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":128,"time":1783486772115,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":129,"time":1783486772141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":130,"time":1783486772142,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":131,"time":1783486772142,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":132,"time":1783486772185,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":133,"time":1783486772186,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":134,"time":1783486772186,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":135,"time":1783486772186,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":136,"time":1783486772204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":137,"time":1783486772205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":138,"time":1783486772205,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":139,"time":1783486772205,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":140,"time":1783486772205,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":141,"time":1783486772228,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded. The user asked me to reply with the single word DONE."}}}} -{"type":"assistant/chunk","seq":142,"time":1783486772229,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":143,"time":1783486772229,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":20,"cacheReadTokens":1408,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":144,"time":1783486772229,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":145,"time":1783486772230,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded. The user asked me to reply with the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":23,"outputTokens":20,"cacheReadTokens":1408,"reasoningTokens":17}},"sourceEventSeqs":[120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144],"surfaceOp":"append"} -{"type":"step/end","seq":146,"time":1783486772230,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":147,"time":1783486772230,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} +{"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783860675271,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783860675275,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783860675275,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783860676324,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783860676324,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783860676434,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":15,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":16,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":19,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} +{"type":"assistant/chunk","seq":21,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":24,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":26,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":27,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":28,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":29,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} +{"type":"assistant/chunk","seq":30,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":31,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":32,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":34,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":35,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":43,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":48,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":49,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":50,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":51,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":54,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":55,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":59,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":60,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":62,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":65,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":69,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":71,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":77,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":79,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":83,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":85,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":87,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":88,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":89,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":90,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":96,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":102,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":105,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":107,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":110,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":116,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":120,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":121,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":127,"time":1783860677497,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126],"surfaceOp":"append"} +{"type":"tool/call","seq":128,"time":1783860677498,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":129,"time":1783860677500,"data":{"id":"fccb92c4-eaa5-49cf-9ccb-9c9b7b90a169","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":130,"time":1783860677503,"data":{"id":"fccb92c4-eaa5-49cf-9ccb-9c9b7b90a169","outcome":"allowed-once"}} +{"type":"tool/result","seq":131,"time":1783860677588,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[128],"surfaceOp":"append"} +{"type":"step/end","seq":132,"time":1783860677589,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":133,"time":1783860677589,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":134,"time":1783860678646,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":135,"time":1783860678646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":136,"time":1783860678781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":137,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":139,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":140,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} +{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":142,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":145,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":146,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":147,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} +{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":150,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":151,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} +{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} +{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} +{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} +{"type":"assistant/chunk","seq":156,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":157,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":158,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":161,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":162,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":165,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":166,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":167,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":169,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":171,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":172,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":177,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":181,"time":1783860679138,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":182,"time":1783860679138,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":183,"time":1783860679138,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index 88fe79c0ce..c3a85270f5 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,13 +1,12 @@ {"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}}","configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":" ret"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"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":" command"}}}} {"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":" sand"}}}} @@ -16,30 +15,44 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"missions"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" set"}}}} {"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":" workspace"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-w"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" danger"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-full"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-access"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" no"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prior"}}}} +{"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":" needed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" justified"}}}} +{"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":" instructed"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" said"}}}} -{"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":" will"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approve"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" permission"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":" proceed"}}}} -{"jsonrpc":"2.0","method":"session/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_ZSEIrZNdgQhL2QJgVHww8689","title":"printf 'escalated\\n' > escalated.txt && cat escalated.txt","kind":"execute","status":"in_progress","rawInput":"printf 'escalated\\n' > escalated.txt && cat escalated.txt","content":[{"type":"content","content":{"type":"text","text":"Write escalated.txt and cat its content"}}]}}} -{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_ZSEIrZNdgQhL2QJgVHww8689"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ZSEIrZNdgQhL2QJgVHww8689","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nescalated\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","title":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","kind":"execute","status":"in_progress","rawInput":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","content":[{"type":"content","content":{"type":"text","text":"Write file outside workspace and verify"}}]}}} +{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nescalated\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":" command"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" succeeded"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" —"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wrote"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":","}}}} +{"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":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"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":"es"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"cal"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" removed"}}}} +{"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":" The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} @@ -53,6 +66,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","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"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/input.json b/examples/acp-agent/tests/snapshots/escalation-rejected/input.json index d645402478..89218b3e30 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/input.json +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/input.json @@ -1,10 +1,19 @@ { "steps": [ - { "op": "initialize" }, - { "op": "newSession" }, - { "op": "prompt", "text": "The sandbox already denied writing escalated.txt in this workspace earlier. Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > escalated.txt && cat escalated.txt, with sandbox_permissions set to workspace-write and the justification 'the user asked to write escalated.txt in the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence that the escalation was rejected, then stop." } + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it \u2014 explain in one short sentence and stop." + } ], "permissionAnswers": [ - { "kind": "reject_once" } + { + "kind": "reject_once" + } ] } diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ee2586bc34..15b0a24274 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,202 +1,212 @@ -{"type":"session","version":0,"id":"46ee90bb-006b-477c-9c56-04dd4923aeb6","createdAt":1783486772550,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-OivXMN"} -{"type":"turn/start","seq":0,"time":1783486772551,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783486772551,"data":{"content":[{"type":"text","text":"The sandbox already denied writing escalated.txt in this workspace earlier. Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > escalated.txt && cat escalated.txt, with sandbox_permissions set to workspace-write and the justification 'the user asked to write escalated.txt in the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence that the escalation was rejected, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783486772552,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783486772552,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783486773136,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783486773136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783486773275,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783486773326,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783486773328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783486773328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783486773328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} -{"type":"assistant/chunk","seq":11,"time":1783486773347,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} -{"type":"assistant/chunk","seq":12,"time":1783486773348,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":1783486773349,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} -{"type":"assistant/chunk","seq":14,"time":1783486773381,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":15,"time":1783486773382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":16,"time":1783486773412,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"printf"}}} -{"type":"assistant/chunk","seq":17,"time":1783486773413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":18,"time":1783486773413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} -{"type":"assistant/chunk","seq":19,"time":1783486773413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} -{"type":"assistant/chunk","seq":20,"time":1783486773414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} -{"type":"assistant/chunk","seq":21,"time":1783486773414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\\n"}}} -{"type":"assistant/chunk","seq":22,"time":1783486773445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'"}}} -{"type":"assistant/chunk","seq":23,"time":1783486773446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" >"}}} -{"type":"assistant/chunk","seq":24,"time":1783486773446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" escalated"}}} -{"type":"assistant/chunk","seq":25,"time":1783486773446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":26,"time":1783486773446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" &&"}}} -{"type":"assistant/chunk","seq":27,"time":1783486773447,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cat"}}} -{"type":"assistant/chunk","seq":28,"time":1783486773473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" escalated"}}} -{"type":"assistant/chunk","seq":29,"time":1783486773473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":30,"time":1783486773473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":31,"time":1783486773473,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":32,"time":1783486773474,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":33,"time":1783486773500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} -{"type":"assistant/chunk","seq":34,"time":1783486773501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":35,"time":1783486773501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":36,"time":1783486773501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":37,"time":1783486773501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":38,"time":1783486773542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":39,"time":1783486773543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":40,"time":1783486773543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":41,"time":1783486773543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"works"}}} -{"type":"assistant/chunk","seq":42,"time":1783486773543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"pace"}}} -{"type":"assistant/chunk","seq":43,"time":1783486773543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-w"}}} -{"type":"assistant/chunk","seq":44,"time":1783486773568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"rite"}}} -{"type":"assistant/chunk","seq":45,"time":1783486773569,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":46,"time":1783486773569,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":47,"time":1783486773569,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":48,"time":1783486773597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} -{"type":"assistant/chunk","seq":49,"time":1783486773632,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} -{"type":"assistant/chunk","seq":50,"time":1783486773667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} -{"type":"assistant/chunk","seq":51,"time":1783486773668,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":52,"time":1783486773669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":53,"time":1783486773685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":54,"time":1783486773716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} -{"type":"assistant/chunk","seq":55,"time":1783486773731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} -{"type":"assistant/chunk","seq":56,"time":1783486773732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":57,"time":1783486773732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reject"}}} -{"type":"assistant/chunk","seq":58,"time":1783486773746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":59,"time":1783486773746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" permission"}}} -{"type":"assistant/chunk","seq":60,"time":1783486773747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} -{"type":"assistant/chunk","seq":61,"time":1783486773773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":62,"time":1783486773773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" so"}}} -{"type":"assistant/chunk","seq":63,"time":1783486773774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":64,"time":1783486773774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" rejection"}}} -{"type":"assistant/chunk","seq":65,"time":1783486773806,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":66,"time":1783486773807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":67,"time":1783486773833,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} -{"type":"assistant/chunk","seq":68,"time":1783486773834,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":69,"time":1783486773861,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":70,"time":1783486773862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":71,"time":1783486773862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} -{"type":"assistant/chunk","seq":72,"time":1783486773862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":73,"time":1783486773862,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":74,"time":1783486773900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":75,"time":1783486773978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":76,"time":1783486773979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":77,"time":1783486774009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":78,"time":1783486774011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1783486774012,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":80,"time":1783486774012,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":81,"time":1783486774012,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":82,"time":1783486774049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":83,"time":1783486774050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":84,"time":1783486774051,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":85,"time":1783486774051,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":86,"time":1783486774068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":87,"time":1783486774068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":88,"time":1783486774069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":89,"time":1783486774069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":90,"time":1783486774069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":91,"time":1783486774069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":92,"time":1783486774107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":93,"time":1783486774108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":94,"time":1783486774108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":95,"time":1783486774108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":96,"time":1783486774108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":97,"time":1783486774138,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":98,"time":1783486774162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":99,"time":1783486774162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":100,"time":1783486774162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":101,"time":1783486774162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1783486774163,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":103,"time":1783486774194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783486774194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":105,"time":1783486774194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":106,"time":1783486774194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":107,"time":1783486774194,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":108,"time":1783486774221,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":109,"time":1783486774221,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"-w"}}} -{"type":"assistant/chunk","seq":110,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"rite"}}} -{"type":"assistant/chunk","seq":111,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" permission"}}} -{"type":"assistant/chunk","seq":112,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":113,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":114,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":115,"time":1783486774283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":116,"time":1783486774303,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":117,"time":1783486774303,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":118,"time":1783486774303,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":119,"time":1783486774304,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":120,"time":1783486774304,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":121,"time":1783486774339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":122,"time":1783486774339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"works"}}} -{"type":"assistant/chunk","seq":123,"time":1783486774339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"pace"}}} -{"type":"assistant/chunk","seq":124,"time":1783486774339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"-w"}}} -{"type":"assistant/chunk","seq":125,"time":1783486774339,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"rite"}}} -{"type":"assistant/chunk","seq":126,"time":1783486774368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":127,"time":1783486774392,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":128,"time":1783486774395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":129,"time":1783486774395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":130,"time":1783486774395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"ification"}}} -{"type":"assistant/chunk","seq":131,"time":1783486774395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":132,"time":1783486774440,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":133,"time":1783486774441,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":134,"time":1783486774441,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":135,"time":1783486774441,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":136,"time":1783486774469,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":137,"time":1783486774469,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":138,"time":1783486774469,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":139,"time":1783486774469,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" escalated"}}} -{"type":"assistant/chunk","seq":140,"time":1783486774469,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":141,"time":1783486774470,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":142,"time":1783486774480,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":143,"time":1783486774480,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":144,"time":1783486774481,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":145,"time":1783486774510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":146,"time":1783486774568,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to retry the exact command `printf 'escalated\\n' > escalated.txt && cat escalated.txt` with `sandbox_permissions` set to `workspace-write` and the justification they specified. They explicitly say they will reject the permission prompt, so after rejection I should explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":147,"time":1783486774569,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt with workspace-write permission\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}}}} -{"type":"assistant/chunk","seq":148,"time":1783486774569,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1269,"outputTokens":197,"cacheReadTokens":0,"reasoningTokens":70}}}} -{"type":"assistant/chunk","seq":149,"time":1783486774569,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":150,"time":1783486774572,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to retry the exact command `printf 'escalated\\n' > escalated.txt && cat escalated.txt` with `sandbox_permissions` set to `workspace-write` and the justification they specified. They explicitly say they will reject the permission prompt, so after rejection I should explain in one short sentence and stop."},{"type":"tool-call","id":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt with workspace-write permission\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}],"usage":{"inputTokens":1269,"outputTokens":197,"cacheReadTokens":0,"reasoningTokens":70}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,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],"surfaceOp":"append"} -{"type":"tool/call","seq":151,"time":1783486774572,"data":{"turn":1,"step":1,"callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > escalated.txt && cat escalated.txt\", \"description\": \"Write escalated.txt with workspace-write permission\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"the user asked to write escalated.txt in the workspace\"}"}} -{"type":"approval/asked","seq":152,"time":1783486774574,"data":{"id":"ed977255-38a3-4c1d-9f4e-0e258ef86e94","toolName":"bash","callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","reason":"escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}} -{"type":"approval/decided","seq":153,"time":1783486774578,"data":{"id":"ed977255-38a3-4c1d-9f4e-0e258ef86e94","outcome":"rejected"}} -{"type":"tool/result","seq":154,"time":1783486774579,"data":{"turn":1,"step":1,"callId":"call_00_ODln9LCQtuvTw4FDZEfe3479","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"workspace-write\""}],"isError":true},"sourceEventSeqs":[151],"surfaceOp":"append"} -{"type":"step/end","seq":155,"time":1783486774579,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":156,"time":1783486774580,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":157,"time":1783486775719,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":158,"time":1783486775719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":159,"time":1783486775820,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":160,"time":1783486775856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":161,"time":1783486775857,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":162,"time":1783486775857,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} -{"type":"assistant/chunk","seq":163,"time":1783486775857,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":164,"time":1783486775879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":165,"time":1783486775879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":166,"time":1783486775921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":167,"time":1783486775922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} -{"type":"assistant/chunk","seq":168,"time":1783486775922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":169,"time":1783486775934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":170,"time":1783486775934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":171,"time":1783486775935,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} -{"type":"assistant/chunk","seq":172,"time":1783486775935,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":173,"time":1783486775935,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":174,"time":1783486775935,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":175,"time":1783486775977,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":176,"time":1783486775978,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":177,"time":1783486775978,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} -{"type":"assistant/chunk","seq":178,"time":1783486775978,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" request"}}} -{"type":"assistant/chunk","seq":179,"time":1783486776011,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":180,"time":1783486776026,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" write"}}} -{"type":"assistant/chunk","seq":181,"time":1783486776027,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":182,"time":1783486776027,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":183,"time":1783486776057,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} -{"type":"assistant/chunk","seq":184,"time":1783486776057,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} -{"type":"assistant/chunk","seq":185,"time":1783486776057,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":186,"time":1783486776058,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":187,"time":1783486776128,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} -{"type":"assistant/chunk","seq":188,"time":1783486776128,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":189,"time":1783486776129,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":190,"time":1783486776129,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} -{"type":"assistant/chunk","seq":191,"time":1783486776145,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":192,"time":1783486776146,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" executed"}}} -{"type":"assistant/chunk","seq":193,"time":1783486776184,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":194,"time":1783486776185,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. I need to explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":195,"time":1783486776185,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The escalation request to write to the workspace was rejected, so the command was not executed."}}}} -{"type":"assistant/chunk","seq":196,"time":1783486776185,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":341,"outputTokens":36,"cacheReadTokens":1152,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":197,"time":1783486776185,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":198,"time":1783486776186,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. I need to explain in one short sentence and stop."},{"type":"text","text":"The escalation request to write to the workspace was rejected, so the command was not executed."}],"usage":{"inputTokens":341,"outputTokens":36,"cacheReadTokens":1152,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":199,"time":1783486776186,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":200,"time":1783486776186,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} +{"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783860679477,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783860679478,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783860679478,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783860680630,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783860680630,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783860680750,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":14,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":16,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":17,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":19,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":21,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":22,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} +{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":27,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":28,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":30,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":31,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} +{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":34,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":36,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":37,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":41,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":42,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":48,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":50,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":51,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":53,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":62,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":64,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":70,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":71,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":74,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":75,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":79,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":83,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":84,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":85,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":91,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":97,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":99,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":103,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":106,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":108,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":109,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":110,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":111,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":112,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} +{"type":"assistant/chunk","seq":113,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} +{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":115,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":119,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":121,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":123,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":124,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":125,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":133,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":134,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":135,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":139,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":144,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":145,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":146,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":147,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} +{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":151,"time":1783860681971,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,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],"surfaceOp":"append"} +{"type":"tool/call","seq":152,"time":1783860681971,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":153,"time":1783860681972,"data":{"id":"0d14d42a-fdd8-4a2c-9618-78c4e5e65cb0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":154,"time":1783860681975,"data":{"id":"0d14d42a-fdd8-4a2c-9618-78c4e5e65cb0","outcome":"rejected"}} +{"type":"tool/result","seq":155,"time":1783860681976,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[152],"surfaceOp":"append"} +{"type":"step/end","seq":156,"time":1783860681976,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":157,"time":1783860681977,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":158,"time":1783860682965,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":159,"time":1783860682965,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":160,"time":1783860683121,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":162,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} +{"type":"assistant/chunk","seq":164,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} +{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":167,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":168,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":172,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":173,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":174,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":175,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} +{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":180,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":181,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} +{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":183,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":186,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":189,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":191,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} +{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":193,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":194,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} +{"type":"assistant/chunk","seq":195,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} +{"type":"assistant/chunk","seq":196,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":198,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":199,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":201,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} +{"type":"assistant/chunk","seq":202,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} +{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":204,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":208,"time":1783860683506,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":209,"time":1783860683506,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":210,"time":1783860683506,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index f8b292e4c3..4d24b7b9ad 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,31 +1,14 @@ {"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}}","configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":" ret"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":" 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":" specific"}}}} {"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":"printf"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"es"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"cal"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ated"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" >"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" escalated"}}}} -{"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":" cat"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" escalated"}}}} -{"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":" 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":"sand"}}}} @@ -36,52 +19,60 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" set"}}}} {"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":"works"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"pace"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-w"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"danger"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-full"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-access"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" justification"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" they"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specified"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"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":" say"}}}} -{"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":" will"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reject"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" permission"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" so"}}}} -{"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":" rejection"}}}} -{"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":" explain"}}}} -{"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":" one"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" short"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sentence"}}}} -{"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":" said"}}}} +{"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":" run"}}}} +{"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":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"box"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_per"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"missions"}}}} +{"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":"tool_call","toolCallId":"call_00_ODln9LCQtuvTw4FDZEfe3479","title":"printf 'escalated\\n' > escalated.txt && cat escalated.txt","kind":"execute","status":"in_progress","rawInput":"printf 'escalated\\n' > escalated.txt && cat escalated.txt","content":[{"type":"content","content":{"type":"text","text":"Write escalated.txt with workspace-write permission"}}]}}} -{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_ODln9LCQtuvTw4FDZEfe3479"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ODln9LCQtuvTw4FDZEfe3479","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: the user rejected escalating this command to \"workspace-write\"\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":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","title":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","kind":"execute","status":"in_progress","rawInput":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","content":[{"type":"content","content":{"type":"text","text":"Write to /tmp and verify, then clean up"}}]}}} +{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: the user rejected escalating this command to \"danger-full-access\"\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":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" escalation"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" As"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" will"}}}} +{"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":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} +{"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":" will"}}}} +{"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":" work"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" around"}}}} +{"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":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explain"}}}} {"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":" one"}}}} @@ -91,21 +82,17 @@ {"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":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" escalation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" request"}}}} -{"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":" write"}}}} -{"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":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" workspace"}}}} -{"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":" user"}}}} {"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":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" permission"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" escalation"}}}} {"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":" the"}}}} +{"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":" command"}}}} -{"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":" not"}}}} -{"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":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" be"}}}} +{"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":"."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/mode-switching/input.json b/examples/acp-agent/tests/snapshots/mode-switching/input.json deleted file mode 100644 index e11cec253e..0000000000 --- a/examples/acp-agent/tests/snapshots/mode-switching/input.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "steps": [ - { "op": "initialize" }, - { "op": "newSession" }, - { "op": "prompt", "text": "Use the bash tool to run exactly: cat notes.txt. Then reply with the single word DONE and stop." }, - { "op": "setConfigOption", "configId": "sandbox-mode", "value": "workspace-write" }, - { "op": "prompt", "text": "Use the bash tool to run exactly this one command in a single call: printf 'switched\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop." }, - { "op": "setConfigOption", "configId": "approval-policy", "value": "never" }, - { "op": "prompt", "text": "Without using any tools, state your current approval policy in one short sentence and stop." } - ] -} diff --git a/examples/acp-agent/tests/snapshots/mode-switching/session.jsonl b/examples/acp-agent/tests/snapshots/mode-switching/session.jsonl deleted file mode 100644 index ef287390aa..0000000000 --- a/examples/acp-agent/tests/snapshots/mode-switching/session.jsonl +++ /dev/null @@ -1,245 +0,0 @@ -{"type":"session","version":0,"id":"eee406bd-e76b-4d87-8fb7-0ffa2dc94fe6","createdAt":1783613224994,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-mNdf7I"} -{"type":"turn/start","seq":0,"time":1783613224997,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783613224997,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat notes.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783613224997,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783613224997,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783613225437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783613225438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783613225658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783613225687,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783613225687,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783613225687,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783613225687,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783613225688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":12,"time":1783613225717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cat"}}} -{"type":"assistant/chunk","seq":13,"time":1783613225718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} -{"type":"assistant/chunk","seq":14,"time":1783613225718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":15,"time":1783613225718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":16,"time":1783613225718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":17,"time":1783613225746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":18,"time":1783613225747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":19,"time":1783613225747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":20,"time":1783613225747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":21,"time":1783613225832,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":22,"time":1783613225832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":23,"time":1783613225859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":24,"time":1783613225860,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":25,"time":1783613225860,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":26,"time":1783613225860,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":27,"time":1783613225860,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":28,"time":1783613225888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":29,"time":1783613225888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"cat"}}} -{"type":"assistant/chunk","seq":30,"time":1783613225889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":" notes"}}} -{"type":"assistant/chunk","seq":31,"time":1783613225889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":32,"time":1783613225889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":33,"time":1783613225945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":34,"time":1783613225945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1783613225945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":36,"time":1783613225945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1783613225946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":38,"time":1783613225979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783613225979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"Show"}}} -{"type":"assistant/chunk","seq":40,"time":1783613225979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":" contents"}}} -{"type":"assistant/chunk","seq":41,"time":1783613225979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":" of"}}} -{"type":"assistant/chunk","seq":42,"time":1783613225980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":" notes"}}} -{"type":"assistant/chunk","seq":43,"time":1783613226003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":44,"time":1783613226004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783613226031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":46,"time":1783613226061,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `cat notes.txt` using the bash tool."}}}} -{"type":"assistant/chunk","seq":47,"time":1783613226061,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","arguments":"{\"command\": \"cat notes.txt\", \"description\": \"Show contents of notes.txt\"}"}}}} -{"type":"assistant/chunk","seq":48,"time":1783613226061,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1202,"outputTokens":81,"cacheReadTokens":0,"reasoningTokens":16}}}} -{"type":"assistant/chunk","seq":49,"time":1783613226061,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1783613226064,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `cat notes.txt` using the bash tool."},{"type":"tool-call","id":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","arguments":"{\"command\": \"cat notes.txt\", \"description\": \"Show contents of notes.txt\"}"}],"usage":{"inputTokens":1202,"outputTokens":81,"cacheReadTokens":0,"reasoningTokens":16}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"tool/call","seq":51,"time":1783613226064,"data":{"turn":1,"step":1,"callId":"call_00_I9sT0gKlfz9YP4dlf2pA3899","name":"bash","arguments":"{\"command\": \"cat notes.txt\", \"description\": \"Show contents of notes.txt\"}"}} -{"type":"tool/result","seq":52,"time":1783613226148,"data":{"turn":1,"step":1,"callId":"call_00_I9sT0gKlfz9YP4dlf2pA3899","content":[{"type":"text","text":"hello from the sandboxed workspace\n"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} -{"type":"step/end","seq":53,"time":1783613226148,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":54,"time":1783613226148,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":55,"time":1783613226790,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":56,"time":1783613226791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":57,"time":1783613226970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":58,"time":1783613227003,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":59,"time":1783613227025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":60,"time":1783613227025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":61,"time":1783613227025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":62,"time":1783613227025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":63,"time":1783613227052,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":64,"time":1783613227053,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":65,"time":1783613227053,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":66,"time":1783613227053,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":67,"time":1783613227053,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":68,"time":1783613227053,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":69,"time":1783613227082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":70,"time":1783613227082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":71,"time":1783613227083,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":72,"time":1783613227110,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":73,"time":1783613227110,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":74,"time":1783613227140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":75,"time":1783613227140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":76,"time":1783613227141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":77,"time":1783613227141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully. The user asked me to reply with the single word DONE."}}}} -{"type":"assistant/chunk","seq":78,"time":1783613227141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":79,"time":1783613227141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":21,"cacheReadTokens":1280,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":80,"time":1783613227141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":81,"time":1783613227142,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully. The user asked me to reply with the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":23,"outputTokens":21,"cacheReadTokens":1280,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":82,"time":1783613227142,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":83,"time":1783613227142,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":84,"time":1783613227169,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"bash/sandbox-mode","seq":85,"time":1783613227169,"data":{"mode":"workspace-write"}} -{"type":"user/message","seq":86,"time":1783613227169,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'switched\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":87,"time":1783613227169,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":88,"time":1783613227737,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":89,"time":1783613227738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":90,"time":1783613227839,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":91,"time":1783613227858,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":92,"time":1783613227859,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":93,"time":1783613227859,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":94,"time":1783613227859,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":95,"time":1783613227859,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":96,"time":1783613227859,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":97,"time":1783613227888,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":98,"time":1783613227888,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":99,"time":1783613227888,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":100,"time":1783613227916,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":101,"time":1783613227916,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" execute"}}} -{"type":"assistant/chunk","seq":102,"time":1783613227917,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":103,"time":1783613227917,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":104,"time":1783613228006,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":105,"time":1783613228006,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":106,"time":1783613228034,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":107,"time":1783613228035,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783613228035,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":109,"time":1783613228035,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":110,"time":1783613228035,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":111,"time":1783613228063,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":112,"time":1783613228063,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":113,"time":1783613228063,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":114,"time":1783613228100,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" read"}}} -{"type":"assistant/chunk","seq":115,"time":1783613228100,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":116,"time":1783613228100,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":117,"time":1783613228121,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":118,"time":1783613228150,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":119,"time":1783613228150,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":120,"time":1783613228151,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":121,"time":1783613228151,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":122,"time":1783613228151,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":123,"time":1783613228179,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":124,"time":1783613228179,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":125,"time":1783613228179,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":126,"time":1783613228179,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"sw"}}} -{"type":"assistant/chunk","seq":127,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"itched"}}} -{"type":"assistant/chunk","seq":128,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":129,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":130,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":131,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":132,"time":1783613228209,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":133,"time":1783613228237,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":134,"time":1783613228237,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":135,"time":1783613228237,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":136,"time":1783613228237,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":137,"time":1783613228237,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":1783613228265,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":139,"time":1783613228325,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command. Let me execute it."}}}} -{"type":"assistant/chunk","seq":140,"time":1783613228325,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","arguments":"{\"description\": \"Write and read out.txt\", \"command\": \"printf 'switched\\\\n' > out.txt && cat out.txt\"}"}}}} -{"type":"assistant/chunk","seq":141,"time":1783613228325,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":70,"outputTokens":90,"cacheReadTokens":1280,"reasoningTokens":15}}}} -{"type":"assistant/chunk","seq":142,"time":1783613228325,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":143,"time":1783613228325,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command. Let me execute it."},{"type":"tool-call","id":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","arguments":"{\"description\": \"Write and read out.txt\", \"command\": \"printf 'switched\\\\n' > out.txt && cat out.txt\"}"}],"usage":{"inputTokens":70,"outputTokens":90,"cacheReadTokens":1280,"reasoningTokens":15}},"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,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":"tool/call","seq":144,"time":1783613228325,"data":{"turn":2,"step":1,"callId":"call_00_htXcbtvwTFK0NqfclLWL7784","name":"bash","arguments":"{\"description\": \"Write and read out.txt\", \"command\": \"printf 'switched\\\\n' > out.txt && cat out.txt\"}"}} -{"type":"tool/result","seq":145,"time":1783613228412,"data":{"turn":2,"step":1,"callId":"call_00_htXcbtvwTFK0NqfclLWL7784","content":[{"type":"text","text":"switched\n"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} -{"type":"step/end","seq":146,"time":1783613228412,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":147,"time":1783613228412,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":148,"time":1783613228863,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":149,"time":1783613228864,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Command"}}} -{"type":"assistant/chunk","seq":150,"time":1783613228966,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}} -{"type":"assistant/chunk","seq":151,"time":1783613228994,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":152,"time":1783613228994,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":153,"time":1783613228994,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":154,"time":1783613228995,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":155,"time":1783613228995,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":156,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":157,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sw"}}} -{"type":"assistant/chunk","seq":158,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"itched"}}} -{"type":"assistant/chunk","seq":159,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":160,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":161,"time":1783613229020,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":162,"time":1783613229048,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":163,"time":1783613229048,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Command executed successfully. The output is \"switched\"."}}}} -{"type":"assistant/chunk","seq":164,"time":1783613229048,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":165,"time":1783613229048,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":47,"outputTokens":14,"cacheReadTokens":1408,"reasoningTokens":11}}}} -{"type":"assistant/chunk","seq":166,"time":1783613229048,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":167,"time":1783613229049,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"Command executed successfully. The output is \"switched\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":47,"outputTokens":14,"cacheReadTokens":1408,"reasoningTokens":11}},"sourceEventSeqs":[148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166],"surfaceOp":"append"} -{"type":"step/end","seq":168,"time":1783613229049,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":169,"time":1783613229049,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":170,"time":1783613229056,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"approval/policy","seq":171,"time":1783613229056,"data":{"policy":"never"}} -{"type":"user/message","seq":172,"time":1783613229056,"data":{"content":[{"type":"text","text":"Without using any tools, state your current approval policy in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":173,"time":1783613229057,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} -{"type":"step/start","seq":174,"time":1783613229057,"data":{"turn":3,"step":1}} -{"type":"request/header-delta","seq":175,"time":1783613229057,"data":{"system":{"keepStart":9,"keepEnd":0,"insert":["{{system}}","{{system}}"]}}} -{"type":"assistant/chunk","seq":176,"time":1783613230100,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":177,"time":1783613230100,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":178,"time":1783613230192,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":179,"time":1783613230221,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":180,"time":1783613230249,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":181,"time":1783613230250,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":182,"time":1783613230250,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" state"}}} -{"type":"assistant/chunk","seq":183,"time":1783613230250,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} -{"type":"assistant/chunk","seq":184,"time":1783613230250,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} -{"type":"assistant/chunk","seq":185,"time":1783613230278,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":186,"time":1783613230279,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":187,"time":1783613230279,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":188,"time":1783613230279,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":189,"time":1783613230306,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} -{"type":"assistant/chunk","seq":190,"time":1783613230307,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":191,"time":1783613230307,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":192,"time":1783613230307,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" From"}}} -{"type":"assistant/chunk","seq":193,"time":1783613230335,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":194,"time":1783613230335,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} -{"type":"assistant/chunk","seq":195,"time":1783613230335,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":196,"time":1783613230335,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":197,"time":1783613230363,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":198,"time":1783613230363,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":199,"time":1783613230364,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":200,"time":1783613230392,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":201,"time":1783613230393,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"never"}}} -{"type":"assistant/chunk","seq":202,"time":1783613230393,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":203,"time":1783613230393,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":204,"time":1783613230393,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meaning"}}} -{"type":"assistant/chunk","seq":205,"time":1783613230423,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" actions"}}} -{"type":"assistant/chunk","seq":206,"time":1783613230450,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":207,"time":1783613230451,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" require"}}} -{"type":"assistant/chunk","seq":208,"time":1783613230451,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":209,"time":1783613230451,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" are"}}} -{"type":"assistant/chunk","seq":210,"time":1783613230451,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":211,"time":1783613230478,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" automatically"}}} -{"type":"assistant/chunk","seq":212,"time":1783613230479,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":213,"time":1783613230479,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":214,"time":1783613230507,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":215,"time":1783613230507,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":216,"time":1783613230507,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":217,"time":1783613230508,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" request"}}} -{"type":"assistant/chunk","seq":218,"time":1783613230508,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":219,"time":1783613230536,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":220,"time":1783613230536,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} -{"type":"assistant/chunk","seq":221,"time":1783613230536,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":222,"time":1783613230537,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":223,"time":1783613230537,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"Appro"}}} -{"type":"assistant/chunk","seq":224,"time":1783613230564,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"vals"}}} -{"type":"assistant/chunk","seq":225,"time":1783613230564,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} -{"type":"assistant/chunk","seq":226,"time":1783613230564,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" never"}}} -{"type":"assistant/chunk","seq":227,"time":1783613230565,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" asked"}}} -{"type":"assistant/chunk","seq":228,"time":1783613230605,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":";"}}} -{"type":"assistant/chunk","seq":229,"time":1783613230606,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" any"}}} -{"type":"assistant/chunk","seq":230,"time":1783613230625,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" action"}}} -{"type":"assistant/chunk","seq":231,"time":1783613230626,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" requiring"}}} -{"type":"assistant/chunk","seq":232,"time":1783613230626,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":233,"time":1783613230653,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":234,"time":1783613230654,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" automatically"}}} -{"type":"assistant/chunk","seq":235,"time":1783613230654,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":236,"time":1783613230654,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":237,"time":1783613230689,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to state my current approval policy without using any tools. From the context, the approval policy is \"never\" — meaning actions that require approval are rejected automatically, and I should not request sandbox escalation."}}}} -{"type":"assistant/chunk","seq":238,"time":1783613230689,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Approvals are never asked; any action requiring approval is automatically rejected."}}}} -{"type":"assistant/chunk","seq":239,"time":1783613230689,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1530,"outputTokens":60,"cacheReadTokens":0,"reasoningTokens":45}}}} -{"type":"assistant/chunk","seq":240,"time":1783613230689,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":241,"time":1783613230690,"data":{"turn":3,"step":1,"content":[{"type":"reasoning","text":"The user wants me to state my current approval policy without using any tools. From the context, the approval policy is \"never\" — meaning actions that require approval are rejected automatically, and I should not request sandbox escalation."},{"type":"text","text":"Approvals are never asked; any action requiring approval is automatically rejected."}],"usage":{"inputTokens":1530,"outputTokens":60,"cacheReadTokens":0,"reasoningTokens":45}},"sourceEventSeqs":[176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,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":1783613230690,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":243,"time":1783613230690,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/input.json b/examples/acp-agent/tests/snapshots/permission-switching/input.json new file mode 100644 index 0000000000..621ab955cd --- /dev/null +++ b/examples/acp-agent/tests/snapshots/permission-switching/input.json @@ -0,0 +1,27 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop." + }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "yolo" + }, + { + "op": "prompt", + "text": "Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop." + }, + { + "op": "prompt", + "text": "Without using any tools, state your current approval policy in one short sentence and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl new file mode 100644 index 0000000000..c47b2c4cec --- /dev/null +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -0,0 +1,235 @@ +{"type":"session","version":0,"id":"df041acb-2f14-4d5f-b6e2-2fb6b9eb6427","createdAt":1783860666204,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-4oJKT4"} +{"type":"turn/start","seq":0,"time":1783860666206,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783860666206,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783860666208,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783860666208,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783860667254,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783860667255,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783860667420,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":13,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":14,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":16,"time":1783860667479,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":17,"time":1783860667501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":18,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":20,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":21,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":22,"time":1783860667595,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":23,"time":1783860667596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":24,"time":1783860667623,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":25,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":26,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":27,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":28,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":29,"time":1783860667656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":31,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":32,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"before"}}} +{"type":"assistant/chunk","seq":33,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":34,"time":1783860667686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":35,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":36,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":37,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":38,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":39,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":40,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":41,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":42,"time":1783860667711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783860667738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":44,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":46,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":48,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":50,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":51,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":52,"time":1783860667834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" read"}}} +{"type":"assistant/chunk","seq":53,"time":1783860667835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":54,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":55,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783860667889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":57,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":58,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}}}} +{"type":"assistant/chunk","seq":59,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":60,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1783860667921,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}],"usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[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":1783860667921,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}} +{"type":"tool/result","seq":63,"time":1783860668018,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1783860668019,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1783860668019,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1783860669017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":67,"time":1783860669017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":68,"time":1783860669117,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":69,"time":1783860669145,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":70,"time":1783860669172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":71,"time":1783860669174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":72,"time":1783860669209,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":73,"time":1783860669210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":74,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"before"}}} +{"type":"assistant/chunk","seq":75,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":76,"time":1783860669236,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":77,"time":1783860669262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":79,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":80,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":81,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":82,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":83,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":84,"time":1783860669322,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":85,"time":1783860669323,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":86,"time":1783860669356,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":87,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":90,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":91,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."}}}} +{"type":"assistant/chunk","seq":92,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":93,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":94,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":95,"time":1783860669358,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}},"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],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1783860669358,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":97,"time":1783860669358,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":98,"time":1783860669383,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":99,"time":1783860669383,"data":{"preset":"yolo"}} +{"type":"bash/sandbox-mode","seq":100,"time":1783860669383,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":101,"time":1783860669383,"data":{"policy":"never"}} +{"type":"user/message","seq":102,"time":1783860669383,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"context/message","seq":103,"time":1783860669384,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} +{"type":"step/start","seq":104,"time":1783860669384,"data":{"turn":2,"step":1}} +{"type":"request/header-delta","seq":105,"time":1783860669384,"data":{"system":{"keepStart":9,"keepEnd":0,"insert":["{{system}}","{{system}}"]}}} +{"type":"assistant/chunk","seq":106,"time":1783860670916,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":107,"time":1783860670916,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":108,"time":1783860671003,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":109,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":110,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":111,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":112,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":113,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":114,"time":1783860671079,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cat"}}} +{"type":"assistant/chunk","seq":115,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" out"}}} +{"type":"assistant/chunk","seq":116,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":117,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":118,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":119,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":120,"time":1783860671097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":121,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":122,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":123,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":124,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":125,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":126,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":127,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":128,"time":1783860671211,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":129,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":130,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":131,"time":1783860671228,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":132,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":133,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":134,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"Read"}}} +{"type":"assistant/chunk","seq":135,"time":1783860671261,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":136,"time":1783860671262,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":137,"time":1783860671301,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":139,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":141,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":142,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":143,"time":1783860671350,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":144,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"cat"}}} +{"type":"assistant/chunk","seq":145,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":146,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":147,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":148,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":149,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":150,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}}}} +{"type":"assistant/chunk","seq":151,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":152,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":153,"time":1783860671436,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}],"usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":154,"time":1783860671436,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}} +{"type":"tool/result","seq":155,"time":1783860671510,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[154],"surfaceOp":"append"} +{"type":"step/end","seq":156,"time":1783860671511,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":157,"time":1783860671511,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":158,"time":1783860673208,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":159,"time":1783860673208,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":160,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":161,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":162,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":163,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":164,"time":1783860673230,"data":{"turn":2,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}},"sourceEventSeqs":[158,159,160,161,162,163],"surfaceOp":"append"} +{"type":"step/end","seq":165,"time":1783860673230,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":166,"time":1783860673230,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":167,"time":1783860673237,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":168,"time":1783860673237,"data":{"content":[{"type":"text","text":"Without using any tools, state your current approval policy in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":169,"time":1783860673238,"data":{"turn":3,"step":1}} +{"type":"assistant/chunk","seq":170,"time":1783860674310,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":171,"time":1783860674310,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":172,"time":1783860674413,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":173,"time":1783860674433,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":174,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":175,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":176,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" state"}}} +{"type":"assistant/chunk","seq":177,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":178,"time":1783860674499,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} +{"type":"assistant/chunk","seq":179,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":180,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":181,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":182,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":183,"time":1783860674525,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":184,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":185,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":186,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" From"}}} +{"type":"assistant/chunk","seq":187,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":188,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} +{"type":"assistant/chunk","seq":189,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}} +{"type":"assistant/chunk","seq":190,"time":1783860674581,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":191,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":192,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":193,"time":1783860674610,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":194,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" changed"}}} +{"type":"assistant/chunk","seq":195,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":196,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":197,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":198,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":199,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":200,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":201,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"never"}}} +{"type":"assistant/chunk","seq":202,"time":1783860674640,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":203,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":204,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"My"}}} +{"type":"assistant/chunk","seq":205,"time":1783860674700,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":206,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":207,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":208,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" set"}}} +{"type":"assistant/chunk","seq":209,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":210,"time":1783860674754,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" \""}}} +{"type":"assistant/chunk","seq":211,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"never"}}} +{"type":"assistant/chunk","seq":212,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\""}}} +{"type":"assistant/chunk","seq":213,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" —"}}} +{"type":"assistant/chunk","seq":214,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" no"}}} +{"type":"assistant/chunk","seq":215,"time":1783860674786,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" actions"}}} +{"type":"assistant/chunk","seq":216,"time":1783860674817,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" requiring"}}} +{"type":"assistant/chunk","seq":217,"time":1783860674846,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":218,"time":1783860674875,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} +{"type":"assistant/chunk","seq":219,"time":1783860674879,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" permitted"}}} +{"type":"assistant/chunk","seq":220,"time":1783860674880,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":221,"time":1783860674904,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":222,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" they"}}} +{"type":"assistant/chunk","seq":223,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} +{"type":"assistant/chunk","seq":224,"time":1783860674939,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":225,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" automatically"}}} +{"type":"assistant/chunk","seq":226,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":227,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."}}}} +{"type":"assistant/chunk","seq":228,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}}}} +{"type":"assistant/chunk","seq":229,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":230,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":231,"time":1783860674941,"data":{"turn":3,"step":1,"content":[{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."},{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}],"usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":232,"time":1783860674941,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":233,"time":1783860674941,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl similarity index 75% rename from examples/acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index e6b16698af..6db494fd93 100644 --- a/examples/acp-agent/tests/snapshots/mode-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,45 +1,5 @@ {"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}}","configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":"cat"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" notes"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_I9sT0gKlfz9YP4dlf2pA3899","title":"cat notes.txt","kind":"execute","status":"in_progress","rawInput":"cat notes.txt","content":[{"type":"content","content":{"type":"text","text":"Show contents of notes.txt"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_I9sT0gKlfz9YP4dlf2pA3899","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nhello from the sandboxed workspace\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":" 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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":" 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"}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"ask","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -49,36 +9,72 @@ {"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":" specific"}}}} {"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":" execute"}}}} -{"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_htXcbtvwTFK0NqfclLWL7784","title":"printf 'switched\\n' > out.txt && cat out.txt","kind":"execute","status":"in_progress","rawInput":"printf 'switched\\n' > out.txt && cat out.txt","content":[{"type":"content","content":{"type":"text","text":"Write and read out.txt"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_htXcbtvwTFK0NqfclLWL7784","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nswitched\n```"}}]}}} -{"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":" executed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":" is"}}}} +{"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":"sw"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"itched"}}}} +{"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_E1vtulcKU1LKUgLahxdR3767","title":"printf 'before\\n' > out.txt && cat out.txt","kind":"execute","status":"in_progress","rawInput":"printf 'before\\n' > out.txt && cat out.txt","content":[{"type":"content","content":{"type":"text","text":"Write and then read out.txt"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_E1vtulcKU1LKUgLahxdR3767","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nbefore\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":" 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":","}}}} +{"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":"before"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"yolo","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":"cat"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" out"}}}} +{"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":" 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_7Jb7FWHNjIBVML49dEJl1990","title":"cat out.txt","kind":"execute","status":"in_progress","rawInput":"cat out.txt","content":[{"type":"content","content":{"type":"text","text":"Read out.txt"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7Jb7FWHNjIBVML49dEJl1990","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nbefore\n```"}}]}}} {"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":5,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":6,"result":{"configOptions":[{"id":"sandbox-mode","name":"Sandbox","description":"The file sandbox mode bash commands in this session run under.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"read-only","name":"read-only"},{"value":"workspace-write","name":"workspace-write"},{"value":"danger-full-access","name":"danger-full-access"}]},{"id":"approval-policy","name":"Approvals","description":"ask: permission prompts reach you; never: they are rejected automatically.","type":"select","currentValue":"never","options":[{"value":"ask","name":"ask"},{"value":"never","name":"never"}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":" state"}}}} -{"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":" 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":" approval"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} @@ -90,45 +86,41 @@ {"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":" context"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" provided"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" approval"}}}} {"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":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" changed"}}}} +{"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":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":"never"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" meaning"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" actions"}}}} -{"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":" require"}}}} -{"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":" are"}}}} -{"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":" automatically"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" 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":" not"}}}} -{"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":" sand"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"box"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" escalation"}}}} -{"jsonrpc":"2.0","method":"session/update","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":"Appro"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"vals"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" are"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" never"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" asked"}}}} -{"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":" any"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" action"}}}} +{"jsonrpc":"2.0","method":"session/update","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":"My"}}}} +{"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":" 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":" set"}}}} +{"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":"never"}}}} +{"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":" no"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" actions"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" requiring"}}}} {"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":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" automatically"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" are"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" permitted"}}}} +{"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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" they"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" are"}}}} {"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":" automatically"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","id":7,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":6,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md similarity index 100% rename from examples/acp-agent/tests/snapshots/mode-switching/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md diff --git a/examples/acp-agent/tests/snapshots/mode-switching/workspace/notes.txt b/examples/acp-agent/tests/snapshots/permission-switching/workspace/notes.txt similarity index 100% rename from examples/acp-agent/tests/snapshots/mode-switching/workspace/notes.txt rename to examples/acp-agent/tests/snapshots/permission-switching/workspace/notes.txt diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..985838408b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -134,6 +134,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'stream(options: GenerateOptions): AsyncIterable', ], }, + { + key: 'permission', + summary: 'The permission service (`ctx.permission`).', + methods: [ + 'current(events: readonly SessionEvent[]): string', + 'resolve(name: string): PresetSpec', + 'optionOf(name: string): PresetOption', + 'set(session: Session, name: string): void', + ], + }, { key: 'sandbox', summary: 'Abstract process-sandbox service.', @@ -514,6 +524,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', }, + { + name: 'ApprovalPolicy', + declaration: 'export type ApprovalPolicy = \'ask\' | \'never\';', + }, { name: 'ApprovalRequest', declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', @@ -742,6 +756,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OwnerToken', declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;', }, + { + name: 'PresetOption', + declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', + }, + { + name: 'PresetSpec', + declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', + }, { name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', diff --git a/packages/ui/README.md b/packages/ui/README.md index 604eff6521..5a6b35cd57 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -6,6 +6,7 @@ Integrations that expose the agent to an external editor or client. These are ** |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | +| `permission/` | User-facing permission presets (request/yolo): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `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`) | diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index a916722c52..b0b038760b 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -28,36 +28,38 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5a5ce9e51f..89fbcddc5c 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -74,10 +74,8 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' -import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Side-effect type import: resolves `ctx.get('permission')` to the service. +import type {} from '@deepseek-ai/dsh-permission' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto @@ -326,7 +324,7 @@ interface SessionRecord { * overlay it truthfully, and a restart before the next turn reverts it — * which `session/load` then reports honestly from the log's fold. */ - pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy } + pendingSwitches: { preset?: string } } /** @@ -545,46 +543,37 @@ export function apply(ctx: Context, config: AcpConfig): void { // --- The ACP Agent method surface ----------------------------------------- /** - * The session config options this composition can honor, with current - * values folded from the AGENT'S OWN session log (`effectiveSandboxMode` / - * `effectiveApprovalPolicy` — the log is the per-session store, so a - * `session/load` reports a resumed session's overrides with no catch-up - * machinery), overlaid with the record's not-yet-anchored pending switches - * (see {@link SessionRecord.pendingSwitches}). Capability-gated like every - * advertised lever: the sandbox option exists only when the mounted - * executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval - * option only when the approval seam is composed — both read + * The session config options this composition can honor: ONE `Mode` + * select over the composed preset table (`ctx.permission` — the product + * layer bundling the sandbox-mode and approval-policy knobs), its current + * value folded from the AGENT'S OWN session log (the log is the + * per-session store, so a `session/load` reports a resumed session's + * preset with no catch-up machinery), overlaid with the record's + * not-yet-anchored pending switch (see + * {@link SessionRecord.pendingSwitches}). Capability-gated like every + * advertised lever: no preset service composed, no options — read * opportunistically so this bridge keeps working in compositions without - * them. + * it. */ const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => { - const options: SessionConfigOption[] = [] - const defaultMode = ctx.get('bash')?.sandboxMode - if (defaultMode !== undefined) { - options.push({ - id: 'sandbox-mode', - name: 'Sandbox', - description: 'The file sandbox mode bash commands in this session run under.', - category: 'mode', - type: 'select', - currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode, - options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })), - }) - } - const approval = ctx.get('approval') - if (approval !== undefined) { - options.push({ - id: 'approval-policy', - name: 'Approvals', - description: 'ask: permission prompts reach you; never: they are rejected automatically.', - type: 'select', - // `?? 'ask'` also shields against a provided stand-in whose config - // never went through the plugin schema (tests do this). - currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask', - options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })), - }) - } - return options + const presets = ctx.get('permission') + if (presets === undefined) return [] + const currentValue = pending.preset ?? presets.current(agent.session.events) + return [{ + id: 'permission', + name: 'Permissions', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', + category: 'mode', + type: 'select', + currentValue, + options: [ + ...presets.names.map((name: string) => presets.optionOf(name)), + // The derived not-a-preset state: visible exactly while it IS the + // current value (a knob state outside the table), switchable FROM, + // never a target — set() below rejects it like any unknown name. + ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], + ], + }] } /** @@ -614,15 +603,12 @@ export function apply(ctx: Context, config: AcpConfig): void { const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches rec.pendingSwitches = {} - const events = rec.agent.session.events - if (pending.sandboxMode !== undefined - && pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) { - setSandboxMode(rec.agent.session, pending.sandboxMode) - } - if (pending.approvalPolicy !== undefined - && pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) { - setApprovalPolicy(rec.agent.session, pending.approvalPolicy) - } + if (pending.preset === undefined) return + const presets = ctx.get('permission') + /* v8 ignore next -- a pending preset exists only if the service answered the + switch; it cannot unmount between that and the next turn in any composition. */ + if (presets === undefined) return + presets.set(rec.agent.session, pending.preset) } // Idle-accepted switches anchor at the next turn's prompt-submit: the turn @@ -881,32 +867,22 @@ export function apply(ctx: Context, config: AcpConfig): void { // advertised; an id this composition never advertised (or an unknown // one) rejects. switch (params.configId) { - case 'sandbox-mode': { - const defaultMode = ctx.get('bash')?.sandboxMode - if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) { - throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`) + case 'permission': { + const presets = ctx.get('permission') + if (presets === undefined) { + throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - const value = params.value as SandboxMode // A no-op switch (the value the session already shows — pending, - // else fold, else default) is acknowledged without recording - // anything: clients that re-push current selections on session - // start must not mint override events out of thin air. - const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode - if (value === current) break - if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value) - else rec.pendingSwitches.sandboxMode = value - break - } - case 'approval-policy': { - const approval = ctx.get('approval') - if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) { - throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`) + // else derived) is acknowledged FIRST and records nothing: + // clients re-push current selections on session start, and the + // derived 'custom' current is only ever valid as such an echo. + const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events) + if (params.value === current) break + if (!presets.names.includes(params.value)) { + throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - const value = params.value as ApprovalPolicy - const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask' - if (value === current) break - if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value) - else rec.pendingSwitches.approvalPolicy = value + if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value) + else rec.pendingSwitches.preset = params.value break } default: diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 1202e44bd3..254a649b5d 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -1,10 +1,10 @@ /** - * Session config options over the bridge: the two per-session knobs - * (`sandbox-mode`, `approval-policy`) advertised from composition capability, - * their current values folded from each session's own log, switching via - * `session/set_config_option` (one log-only event per switch — the log is the - * store), and a resumed session reporting its overrides back on - * `session/load` with no catch-up machinery. + * Session config options over the bridge: ONE user-facing `Permissions` + * select (`ctx.permission`'s preset table — each choice bundles a sandbox + * mode and an approval policy), its current value folded from each session's + * own log, switching via `session/set_config_option` (the preset event plus + * its knob write-throughs — the log is the store), and a resumed session + * reporting its preset back on `session/load` with no catch-up machinery. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -14,50 +14,37 @@ import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import * as Invariants from '@deepseek-ai/dsh-invariants' import ApprovalService from '@deepseek-ai/dsh-user-approval' -import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import PermissionService from '@deepseek-ai/dsh-permission' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' /** * The REAL local executor reporting a confining default — `sandboxMode` is * the documented capability override point (`dsh-bash-sandbox` overrides it * the same way), so the bridge sees exactly what a sandboxing composition - * advertises without this suite dragging in a kernel sandbox stack. + * advertises without this suite dragging in a kernel sandbox stack. It + * reports `workspace-write`: the shipped `request` preset's bundle, which + * the permission service validates the composition defaults against. */ class SandboxedLocalExecutor extends LocalBashExecutor { override get sandboxMode(): SandboxMode { - return 'read-only' + return 'workspace-write' } } -/** The exact option payloads the bridge advertises (pinned verbatim). */ -function sandboxOption(currentValue: SandboxMode): object { +/** The exact option payload the bridge advertises (pinned verbatim). */ +function permissionOption(currentValue: string): object { return { - id: 'sandbox-mode', - name: 'Sandbox', - description: 'The file sandbox mode bash commands in this session run under.', + id: 'permission', + name: 'Permissions', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', category: 'mode', type: 'select', currentValue, options: [ - { value: 'read-only', name: 'read-only' }, - { value: 'workspace-write', name: 'workspace-write' }, - { value: 'danger-full-access', name: 'danger-full-access' }, - ], - } -} - -function approvalOption(currentValue: ApprovalPolicy): object { - return { - id: 'approval-policy', - name: 'Approvals', - description: 'ask: permission prompts reach you; never: they are rejected automatically.', - type: 'select', - currentValue, - options: [ - { value: 'ask', name: 'ask' }, - { value: 'never', name: 'never' }, + { value: 'request', name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.' }, + { value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' }, ], } } @@ -75,144 +62,113 @@ describe('acp bridge — session config options', () => { await rm(storageDir, { recursive: true, force: true }) }) - /** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */ - async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable[0]>['script'] } = {}): Promise { + /** A harness composing the full preset stack (confining executor + approval seam + permission presets). */ + async function presetStack(options: { script?: NonNullable[0]>['script'] } = {}): Promise { const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} }) // The dev invariants police turn-enclosure: an idle switch that appended // outside a turn would throw right here in the suite, not in production. await harness.ctx.plugin(Invariants) await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) - await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {}) + await harness.ctx.plugin(ApprovalService) + await harness.ctx.plugin(PermissionService) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) return harness } - it('advertises no configOptions in a composition with neither knob', async () => { + it('advertises no configOptions without the permission service — even with both knobs composed', async () => { h = await makeBridgeHarness({ storageDir }) + await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) + await h.ctx.plugin(ApprovalService) await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) expect(res.configOptions).toBeUndefined() }) - it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => { - h = await makeBridgeHarness({ storageDir, withBash: true }) - await h.ctx.plugin(ApprovalService) - await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + it('advertises the Permissions select with the default preset current', async () => { + h = await presetStack() const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([approvalOption('ask')]) - }) - - it('advertises both knobs with capability-derived currents (config default included)', async () => { - h = await bothKnobs({ policy: 'never' }) - const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')]) + expect(res.configOptions).toEqual([permissionOption('request')]) }) it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => { - h = await bothKnobs({ script: [textResponse('ok')] }) + h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')]) - const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) - expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')]) + const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + expect(after.configOptions).toEqual([permissionOption('yolo')]) - // Idle: nothing in the log yet — turn-enclosure forbids a bare append - // (the dev invariants in this suite would throw), so the switch lives on - // the record until a turn opens. + // Idle: nothing in the log yet — turn-enclosure forbids a bare append. const session = h.ctx.agents.list()[0]?.session - expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) + expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) - // The next turn anchors both switches inside itself, one event per knob. await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = session?.events ?? [] - expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }]) + expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }]) + expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) const turnStart = events.findIndex(e => e.type === 'turn/start') - const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode') + const anchored = events.findIndex(e => e.type === 'permission/preset') expect(turnStart).toBeGreaterThanOrEqual(0) expect(anchored).toBeGreaterThan(turnStart) }) - it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => { - h = await bothKnobs({ script: [textResponse('ok')] }) + it('an idle flip-flop anchors as ONE switch (last write wins)', async () => { + h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + expect(again.configOptions).toEqual([permissionOption('yolo')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) - // Idle again AFTER a completed turn (the log now ends in turn/end): a new - // switch pends rather than appending outside the closed turn. - const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' }) - expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' }) - expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1) - }) - - it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => { - h = await bothKnobs({ script: [textResponse('ok')] }) - const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - // Re-pushing the composition default (what clients that echo current - // selections on session start do) must not mint an override event. - const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' }) - expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' }) - // Re-sending a PENDING value keeps the pending switch alive (it is what - // the session shows), rather than cancelling it. - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' }) - await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) - const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0) - expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }]) + expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1) + // Between turns (a closed turn in the log) a switch still pends — the + // enclosure fold walks past the turn/end — and anchors with the NEXT turn. + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) + expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1) }) it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => { - h = await bothKnobs({ script: [textResponse('ok')] }) + h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' }) - expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) + expect(back.configOptions).toEqual([permissionOption('request')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0) + expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) + }) + + it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => { + h = await presetStack({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) + expect(echo.configOptions).toEqual([permissionOption('request')]) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + expect(repeat.configOptions).toEqual([permissionOption('yolo')]) + await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) + const events = h.ctx.agents.list()[0]?.session.events ?? [] + expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }]) }) it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => { - h = await bothKnobs({ script: ['hang'] }) + h = await presetStack({ script: ['hang'] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) // Give the loop a tick to open the turn (the turns.spec hang idiom). await new Promise(resolve => setTimeout(resolve, 30)) - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }) - await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) const events = h.ctx.agents.list()[0]?.session.events ?? [] const turnStart = events.findIndex(e => e.type === 'turn/start') - const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode') + const anchored = events.findIndex(e => e.type === 'permission/preset') expect(turnStart).toBeGreaterThanOrEqual(0) expect(anchored).toBeGreaterThan(turnStart) + expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true) expect(events.some(e => e.type === 'approval/policy')).toBe(true) await h.client.cancel({ sessionId }) await hung }) - it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => { - h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) - h.ctx.provide('approval', { config: {} } as unknown as InstanceType) - await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([approvalOption('ask')]) - const sessionId = res.sessionId - // The schema-less config also shields the no-op guard ('ask' by the ?? fallback)… - const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' }) - expect(echo.configOptions).toEqual([approvalOption('ask')]) - // …and the anchor-time comparison: a real switch under the stand-in still anchors. - await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) - await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) - const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) - }) - it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => { h = await makeBridgeHarness({ storageDir }) await h.ctx.plugin(ApprovalService) @@ -221,40 +177,72 @@ describe('acp bridge — session config options', () => { await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' })) .rejects.toThrow(/unknown config option/) - // sandbox-mode exists as a concept but THIS composition never advertised it. - await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })) - .rejects.toThrow(/unknown sandbox-mode value/) - await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true })) + // `permission` exists as a concept but THIS composition never advertised it. + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })) + .rejects.toThrow(/unknown permission value/) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true })) .rejects.toThrow(/select; boolean values are not accepted/) - await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' })) - .rejects.toThrow(/unknown approval-policy value/) + }) + + it('rejects an out-of-vocabulary preset on an advertising composition', async () => { + h = await presetStack() + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' })) + .rejects.toThrow(/unknown permission value/) }) it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => { - h = await bothKnobs() + h = await presetStack() const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) - // B sees its own composition defaults, not A's pending switch... - const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' }) - expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')]) + await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' }) + // B sees the composition default, not A's pending switch... + const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'request' }) + expect(bAfter.configOptions).toEqual([permissionOption('request')]) // ...and A keeps its own state, untouched by B's. - const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) - expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')]) + const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' }) + expect(aAfter.configOptions).toEqual([permissionOption('yolo')]) }) - it('session/load reports a resumed session\'s overrides from its own log', async () => { - h = await bothKnobs({ script: [textResponse('ok')] }) + it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => { + h = await presetStack() const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' }) - await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' }) + // Drift a knob out from under the table (a plugin writing the knob + // directly — the raw setters remain public mechanism), inside its own + // turn: the dev invariants enforce turn-enclosure here too. + const agent = h.ctx.agents.list()[0] + if (agent === undefined) throw new Error('expected an agent') + agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.session.append('bash/sandbox-mode', { mode: 'read-only' }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // The echo of the derived current is a no-op, not an unknown-value error… + const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }) + const option = echo.configOptions?.[0] + expect(option).toMatchObject({ currentValue: 'custom' }) + if (option === undefined || !('options' in option)) throw new Error('expected a select option') + expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo', 'custom']) + // …while custom as a TARGET from a real preset stays rejected: switching + // away is ordinary, and the custom entry disappears from the options. + const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + const afterOption = away.configOptions?.[0] + expect(afterOption).toMatchObject({ currentValue: 'yolo' }) + if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option') + expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo']) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })) + .rejects.toThrow(/unknown permission value/) + }) + + it('session/load reports a resumed session\'s preset from its own log', async () => { + h = await presetStack({ script: [textResponse('ok')] }) + const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) // One turn checkpoints the log (the switch events flush with it). await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] }) await h.dispose() h = undefined - loader = await bothKnobs() + loader = await presetStack() const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')]) + expect(res.configOptions).toEqual([permissionOption('yolo')]) }) }) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 0c82438277..387e0d0c53 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../user-approval" }, + { + "path": "../permission" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md new file mode 100644 index 0000000000..57526c9ad2 --- /dev/null +++ b/packages/ui/permission/README.md @@ -0,0 +1,7 @@ +# @deepseek-ai/dsh-permission + +User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `request` (`workspace-write` + `ask`) and `yolo` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary. + +A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched. + +Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's sandbox variant](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over. diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json new file mode 100644 index 0000000000..b6833791e4 --- /dev/null +++ b/packages/ui/permission/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-permission", + "description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own 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-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts new file mode 100644 index 0000000000..84c9fef4a6 --- /dev/null +++ b/packages/ui/permission/src/index.ts @@ -0,0 +1,236 @@ +/** + * User-facing PERMISSION PRESETS: one product-level knob over the two + * mechanism knobs. A preset names a bundle — its sandbox mode + * (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a + * user picks `request` or `yolo` where the mechanism tiers stay orthogonal + * capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event + * records the chosen bundle (the audit fact reverse-mapping cannot recover — + * two presets may share knob values and differ only in composed policy, the + * planned `agent` preset being the standing example), then each knob event + * follows through its own THE-write-path setter, skipping values the session + * already effectively has. Every existing consumer (executor stamping, the + * approval gate, narrators, resume) keeps reading its own knob fold, + * untouched. + * + * @module dsh-permission + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' + +declare module 'cordis' { + interface Context { + permission: PermissionService + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's permission preset was switched — log-only (the + * `bash/sandbox-mode` precedent): durable and replayable, never in the + * model transcript. The LAST such event is the session's preset + * ({@link effectivePermissionPreset}); the knob events the switch wrote + * through follow it in the same turn, and they — not this record of the + * user's choice — are what execution reads. + */ + 'permission/preset': { preset: string } + } +} + +/** + * One preset's knob bundle — the sandbox mode and approval policy a session + * runs under while the preset is active — plus its presentation. + */ +export interface PresetSpec { + /** The `bash/sandbox-mode` value the preset writes through. */ + sandbox: SandboxMode + /** The `approval/policy` value the preset writes through. */ + approval: ApprovalPolicy + /** The display label a client shows for this preset; the raw table key when omitted. */ + name?: string + /** One user-facing sentence on what the preset means; omitted when not configured. */ + description?: string +} + +/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ +export interface PresetOption { + /** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means. */ + description?: string +} + +/** + * The derived not-a-preset state: the session's effective knob values match + * no table entry (composition defaults outside the table, or a knob moved + * out from under the last-chosen preset). Never a switch target and never + * an event payload — {@link PermissionService.current} derives it, and the + * presentation layer shows it as a selectable-FROM-only current value. + */ +export const CUSTOM_PRESET = 'custom' + +/** + * The session's permission-preset override: the last `permission/preset` event in the + * log, or undefined when the session never switched (callers apply the + * plugin's configured default). The pure fold — resume needs no catch-up + * machinery because replaying the log IS the state. + * @param events - session events in log order (other event types are skipped). + * @returns the preset of the last switch event, or undefined without one. + */ +export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'permission/preset') return event.data.preset + } + return undefined +} + +/** The {@link PermissionService} config: the deployment's preset table. */ +export interface Config { + /** + * The preset table: name → knob bundle. Defaults to `request` + * (workspace-write + ask) and `yolo` (danger-full-access + never). The + * name `custom` is reserved for the derived not-a-preset state. + */ + presets?: Record +} + +/** + * The permission service (`ctx.permission`). Owns the deployment's preset + * table and THE write path for preset switches; presentation layers (the ACP + * bridge's single `Permissions` select) advertise {@link names} and call + * {@link set}. Composing it REQUIRES both mechanism knobs — a confining + * `ctx.bash` executor and the `ctx.approval` seam. A knob state matching no + * table entry is not an error but the derived {@link CUSTOM_PRESET} state: + * shown as the current value, never a switch target. + */ +export class PermissionService extends Service { + // Inline schema call: the config catalog walks `static Config` statically. + static Config: z = z.object({ + presets: z.dict(z.object({ + sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(), + approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(), + name: z.string(), + description: z.string(), + })).default({ + request: { + sandbox: 'workspace-write', approval: 'ask', + name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.', + }, + yolo: { + sandbox: 'danger-full-access', approval: 'never', + name: 'YOLO', description: 'Full file access, no approval prompts.', + }, + }), + }) + + static inject = ['bash', 'approval'] + + private readonly presets: Record + + constructor(ctx: Context, config: Config) { + super(ctx, 'permission') + // The schema defaulted the table — the cast records that runtime fact. + this.presets = config.presets as Record + if (CUSTOM_PRESET in this.presets) { + throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`) + } + if (ctx.bash.sandboxMode === undefined) { + throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') + } + } + + /** + * The advertised preset names, in the preset table's declaration order. + * @returns every switchable preset name. + */ + get names(): readonly string[] { + return Object.keys(this.presets) + } + + /** + * The preset a session is on right now, derived from the EFFECTIVE knob + * values (fold ?? composition default per knob): the last-chosen preset + * when its bundle still matches (presets may share bundles — the fold + * breaks the tie), else the first table entry that matches, else + * {@link CUSTOM_PRESET} — a mismatch is a state, not an error. + * @param events - the session's events in log order. + * @returns the effective preset name, or `custom` when nothing matches. + */ + current(events: readonly SessionEvent[]): string { + const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode + const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask' + const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval + const folded = effectivePermissionPreset(events) + if (folded !== undefined) { + const spec = this.presets[folded] + if (spec !== undefined && matches(spec)) return folded + } + for (const [name, spec] of Object.entries(this.presets)) { + if (matches(spec)) return name + } + return CUSTOM_PRESET + } + + /** + * A preset's knob bundle, for consumers presenting or validating one. + * @param name - the preset name to resolve. + * @returns the bundle; throws on a name outside the table (fails loud — + * an unvalidated caller handed the service an unknown preset). + */ + resolve(name: string): PresetSpec { + const spec = this.presets[name] + if (spec === undefined) { + throw new Error(`permission: unknown preset "${name}" (known: ${Object.keys(this.presets).join(', ')})`) + } + return spec + } + + /** + * The select-option presentation of one advertisable value: a table entry + * (label/description from its spec, the raw key standing in for a missing + * label) or the derived {@link CUSTOM_PRESET} with its fixed presentation. + * @param name - a table key, or `custom`. + * @returns the option a client renders; throws on any other name. + */ + optionOf(name: string): PresetOption { + if (name === CUSTOM_PRESET) { + return { value: CUSTOM_PRESET, name: 'Custom', description: 'A hand-set knob combination outside the preset table.' } + } + const spec = this.resolve(name) + return { value: name, name: spec.name ?? name, ...spec.description !== undefined ? { description: spec.description } : {} } + } + + /** + * THE write path for a preset switch: appends one `permission/preset` event when + * `name` differs from the session's current preset, then writes each knob + * through its own setter, skipping values the session already effectively + * has — a net-zero switch appends nothing (the log records switches, not + * select clicks). + * @param session - the session the switch belongs to. + * @param name - the preset to switch to (validated via {@link resolve}). + */ + set(session: Session, name: string): void { + const spec = this.resolve(name) + if (this.current(session.events) !== name) { + session.append('permission/preset', { preset: name }) + } + const events = session.events + if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) { + setSandboxMode(session, spec.sandbox) + } + if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { + setApprovalPolicy(session, spec.approval) + } + } +} + +export default PermissionService diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts new file mode 100644 index 0000000000..8bf0bb075f --- /dev/null +++ b/packages/ui/permission/tests/permission.spec.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' +import type { Config } from '@deepseek-ai/dsh-permission' + +/** Mount the service over stand-in bash/approval capabilities (the two facts it validates against). */ +async function mounted(options: { + config?: Config + bashDefault?: SandboxMode | undefined + approvalDefault?: ApprovalPolicy | undefined +} = {}): Promise { + const ctx = new Context() + ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' }) + ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } }) + await ctx.plugin(PermissionService, options.config ?? {}) + return ctx +} + +/** A real Session seeded with one opened turn (events append without ceremony in unit scope). */ +function freshSession(id: string): Session { + return new Session(SessionId(id)) +} + +describe('effectivePermissionPreset', () => { + it('folds to the last event, or undefined without one', () => { + const session = freshSession('sess-fold') + expect(effectivePermissionPreset(session.events)).toBeUndefined() + session.append('permission/preset', { preset: 'yolo' }) + session.append('permission/preset', { preset: 'request' }) + expect(effectivePermissionPreset(session.events)).toBe('request') + }) +}) + +describe('PermissionService', () => { + it('advertises the preset table in declaration order and resolves bundles', async () => { + const ctx = await mounted() + expect(ctx.permission.names).toEqual(['request', 'yolo']) + expect(ctx.permission.resolve('yolo')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' }) + expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/) + }) + + it('current() derives from the effective knobs: composition defaults hit request, a switch hits its preset', async () => { + const ctx = await mounted() + const session = freshSession('sess-current') + expect(ctx.permission.current(session.events)).toBe('request') + ctx.permission.set(session, 'yolo') + expect(ctx.permission.current(session.events)).toBe('yolo') + }) + + it('a knob state matching no table entry derives custom — a state, not an error', async () => { + const ctx = await mounted() + const session = freshSession('sess-custom') + session.append('bash/sandbox-mode', { mode: 'read-only' }) + expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) + // Switching FROM custom is an ordinary write-through; custom itself is + // never a target. + ctx.permission.set(session, 'yolo') + expect(ctx.permission.current(session.events)).toBe('yolo') + expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) + }) + + it('composition defaults outside the table derive custom at zero events', async () => { + const ctx = await mounted({ approvalDefault: 'never' }) + const session = freshSession('sess-defaults-custom') + expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) + }) + + it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { + const ctx = await mounted({ config: { presets: { + request: { sandbox: 'workspace-write', approval: 'ask' }, + agentish: { sandbox: 'workspace-write', approval: 'ask' }, + yolo: { sandbox: 'danger-full-access', approval: 'never' }, + } } }) + const session = freshSession('sess-tie') + // Same bundle as request, chosen explicitly: the fold names it. + ctx.permission.set(session, 'agentish') + expect(ctx.permission.current(session.events)).toBe('agentish') + // A knob drifts: the fold's bundle no longer matches → reverse map wins. + session.append('approval/policy', { policy: 'never' }) + session.append('bash/sandbox-mode', { mode: 'danger-full-access' }) + expect(ctx.permission.current(session.events)).toBe('yolo') + }) + + it('set() writes through: one preset event plus both knob events', async () => { + const ctx = await mounted() + const session = freshSession('sess-set') + ctx.permission.set(session, 'yolo') + expect(session.events.map(e => [e.type, e.data])).toEqual([ + ['permission/preset', { preset: 'yolo' }], + ['bash/sandbox-mode', { mode: 'danger-full-access' }], + ['approval/policy', { policy: 'never' }], + ]) + }) + + it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => { + const ctx = await mounted() + const session = freshSession('sess-noop') + ctx.permission.set(session, 'request') + expect(session.events).toHaveLength(0) + }) + + it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => { + const ctx = await mounted() + const session = freshSession('sess-drift') + ctx.permission.set(session, 'yolo') + // A knob drifts out from under the preset (a direct setter call, a test + // scenario): the session derives custom, and re-asserting the preset is + // a real switch again — choice re-recorded, only the drifted knob moves. + session.append('bash/sandbox-mode', { mode: 'read-only' }) + ctx.permission.set(session, 'yolo') + const tail = session.events.slice(4) + expect(tail.map(e => [e.type, e.data])).toEqual([ + ['permission/preset', { preset: 'yolo' }], + ['bash/sandbox-mode', { mode: 'danger-full-access' }], + ]) + }) + + it('rejects composition over a non-confining executor at load', async () => { + await expect(mounted({ bashDefault: undefined })) + .rejects.toThrow(/does not confine/) + }) + + it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => { + const ctx = await mounted() + expect(ctx.permission.optionOf('yolo')).toEqual({ value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' }) + expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }) + const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } }) + expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' }) + expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/) + }) + + it('rejects a table entry named custom (reserved for the derived state)', async () => { + await expect(mounted({ config: { presets: { custom: { sandbox: 'read-only', approval: 'ask' } } } })) + .rejects.toThrow(/reserved for the derived not-a-preset state/) + }) + + it('reads a schema-less approval stand-in as the ask default', async () => { + const ctx = await mounted({ approvalDefault: undefined }) + const session = freshSession('sess-standin') + ctx.permission.set(session, 'request') + expect(session.events).toHaveLength(0) + expect(ctx.permission.current(session.events)).toBe('request') + }) +}) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json new file mode 100644 index 0000000000..8b9cff62b4 --- /dev/null +++ b/packages/ui/permission/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../user-approval" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 649a986385..4d339c6fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1161,6 +1161,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../permission '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1252,6 +1255,28 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/permission: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0db3be2f6b..c268023e39 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -188,6 +188,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tools', 'tool-bash'], note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.', }, + { + key: 'permission', + pkg: 'permission', + title: 'Permission presets', + mode: 'core', + implementations: [], + consumers: ['acp'], + note: 'User-facing preset table (request/yolo) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.', + }, { key: 'codeRuntime', pkg: 'code-runtime', diff --git a/tsconfig.build.json b/tsconfig.build.json index 9c236fe02a..323e9820e0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, + { "path": "./packages/ui/permission" }, { "path": "./packages/core/tools" }, { "path": "./packages/skill/skill" }, { "path": "./packages/skill/skill-local" }, diff --git a/tsconfig.json b/tsconfig.json index 2ab1a5da30..f30d77353f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, + { "path": "./packages/ui/permission" }, { "path": "./packages/core/tools" }, { "path": "./packages/skill/skill" }, { "path": "./packages/skill/skill-local" }, From e3d7bb62d8542cc5516e78ecac4da5321f1420b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:09:44 +0800 Subject: [PATCH 29/31] fix(permission): address human review feedback --- docs/capability-seams.md | 2 +- docs/config-catalog.md | 8 +- .../2026-06-14-acp-agent-client-protocol.md | 4 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../implemented/feature/2026-07-06-sandbox.md | 18 +- examples/AGENTS.md | 2 +- examples/README.md | 2 +- examples/acp-agent/README.md | 19 +- examples/acp-agent/composition.md | 13 +- examples/acp-agent/cordis.yml | 39 +- .../acp-agent/sandbox.cordis.snapshot.yml | 30 -- examples/acp-agent/sandbox.cordis.yml | 68 --- examples/acp-agent/tests/acp.e2e.ts | 11 +- examples/acp-agent/tests/acp.snapshot.ts | 48 +- examples/acp-agent/tests/escalation.e2e.ts | 36 +- examples/acp-agent/tests/hooks.e2e.ts | 4 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../advanced-toolchain/stdout.golden.jsonl | 2 +- .../system-prompt.golden.md | 42 +- .../snapshots/both-mode-turn/session.jsonl | 2 +- .../both-mode-turn/stdout.golden.jsonl | 2 +- .../both-mode-turn/system-prompt.golden.md | 42 +- .../snapshots/cancel/stdout.golden.jsonl | 2 +- .../code-mode-turn/stdout.golden.jsonl | 2 +- .../code-mode-turn/system-prompt.golden.md | 42 +- .../tests/snapshots/config-options/input.json | 4 +- .../config-options/stdout.golden.jsonl | 6 +- .../error-finish/stdout.golden.jsonl | 2 +- .../snapshots/escalation-approved/input.json | 5 + .../escalation-approved/session.jsonl | 367 +++++++------- .../escalation-approved/stdout.golden.jsonl | 5 +- .../snapshots/escalation-rejected/input.json | 5 + .../escalation-rejected/session.jsonl | 421 ++++++++-------- .../escalation-rejected/stdout.golden.jsonl | 5 +- .../tests/snapshots/fs-edit/session.jsonl | 4 +- .../snapshots/fs-edit/stdout.golden.jsonl | 10 +- .../snapshots/fs-policy-reject/session.jsonl | 6 +- .../fs-policy-reject/stdout.golden.jsonl | 14 +- .../snapshots/fs-read-window/session.jsonl | 2 +- .../fs-read-window/stdout.golden.jsonl | 6 +- .../tests/snapshots/fs-read/session.jsonl | 2 +- .../snapshots/fs-read/stdout.golden.jsonl | 6 +- .../fs-terminal-card/stdout.golden.jsonl | 2 +- .../fs-write-overwrite/session.jsonl | 4 +- .../fs-write-overwrite/stdout.golden.jsonl | 10 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../snapshots/fs-write/stdout.golden.jsonl | 6 +- .../snapshots/handshake/stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 114 ++--- .../hook-cc-pretool-ask/stdout.golden.jsonl | 4 +- .../hook-cc-pretool-deny/stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../hook-cc-stop-continue/stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../stdout.golden.jsonl | 2 +- .../snapshots/multi-turn/stdout.golden.jsonl | 2 +- .../snapshots/permission-switching/input.json | 7 +- .../permission-switching/session.jsonl | 469 +++++++++--------- .../permission-switching/stdout.golden.jsonl | 11 +- .../system-prompt.golden.md | 4 +- .../repeat-tool-guard/stdout.golden.jsonl | 2 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../snapshots/skill-load/stdout.golden.jsonl | 2 +- .../skill-load/system-prompt.golden.md | 11 +- .../subagent-fork/stdout.golden.jsonl | 2 +- .../subagent-mixed/stdout.golden.jsonl | 2 +- .../subagent-multi/stdout.golden.jsonl | 2 +- .../subagent-spawn/stdout.golden.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../snapshots/text-turn/stdout.golden.jsonl | 2 +- .../text-turn/system-prompt.golden.md | 11 +- .../snapshots/todo-plan/stdout.golden.jsonl | 2 +- .../tool-call-turn/stdout.golden.jsonl | 2 +- .../workflow-run/stdout.golden.jsonl | 2 +- .../snapshots/workspace-edit/session.jsonl | 2 +- .../workspace-edit/stdout.golden.jsonl | 6 +- package.json | 3 +- packages/bash/README.md | 2 +- packages/bash/bash-sandbox/README.md | 2 +- packages/sandbox/README.md | 2 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/subagent/subagent-acp/README.md | 2 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 6 +- packages/support/acp-snapshot/src/harness.ts | 2 +- packages/ui/README.md | 2 +- packages/ui/acp-agent/README.md | 4 +- packages/ui/acp-agent/src/bin.ts | 11 +- packages/ui/acp-agent/tests/built-bin.e2e.ts | 4 +- packages/ui/acp-agent/tests/load-path.e2e.ts | 2 +- packages/ui/acp/README.md | 4 +- packages/ui/acp/acp-feature-support.md | 8 +- packages/ui/acp/src/index.ts | 2 +- packages/ui/acp/tests/config-options.spec.ts | 66 +-- packages/ui/jsonrpc-agent/README.md | 2 +- packages/ui/jsonrpc-agent/src/bin.ts | 2 +- packages/ui/permission/README.md | 4 +- packages/ui/permission/src/index.ts | 19 +- .../ui/permission/tests/permission.spec.ts | 48 +- pnpm-lock.yaml | 3 + python/sdk-runtime/package.json | 1 + scripts/demo-code-mode.mjs | 2 +- scripts/gen-doc-graphs.ts | 2 +- 111 files changed, 1051 insertions(+), 1189 deletions(-) delete mode 100644 examples/acp-agent/sandbox.cordis.snapshot.yml delete mode 100644 examples/acp-agent/sandbox.cordis.yml diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 196d9ba17a..68b64b6ed9 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -189,7 +189,7 @@ flowchart LR | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`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 replace bash-local without touching them. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | -| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (request/yolo) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | +| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb20c5e9f0..389e106a78 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:250`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -452,9 +452,9 @@ Requires: `bash` · `approval` /** The {@link PermissionService} config: the deployment's preset table. */ export interface Config { /** - * The preset table: name → knob bundle. Defaults to `request` - * (workspace-write + ask) and `yolo` (danger-full-access + never). The - * name `custom` is reserved for the derived not-a-preset state. + * The preset table: name → knob bundle. Defaults to `workspace-write` + * (workspace-write + ask) and `danger-full-access` (danger-full-access + + * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record } diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index 293b1bbde6..317d244d29 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -24,7 +24,7 @@ Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentRe Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. -The bridge advertises ACP config options instead of session modes. `sandbox-mode` exists only when the mounted bash executor reports sandbox capability, and `approval-policy` exists only when `ctx.approval` is composed. Each option is an independent select whose current value is the session event fold over the composition default. `session/set_config_option` validates against the owning domain vocabulary and writes through `setSandboxMode` or `setApprovalPolicy`. An open-turn switch appends immediately; an idle switch is overlaid in the response and anchored at the next turn start. Until that anchor it is memory-only and a crash reverts to the durable fold. ACP session modes are deliberately not modeled because one mode list cannot represent these orthogonal knobs and config options are the forward protocol surface. Runtime model selection remains outside this decision; `AcpConfig.model` is connection-wide. +The bridge advertises ACP config options instead of session modes. When `ctx.permission` is composed, it exposes one `permission` select whose options come from the deployment's preset table; the shipped presets are `workspace-write` and `danger-full-access`, and each bundles a sandbox mode with an approval policy. The current value comes from `PermissionService.current()`, including the derived switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` validates through `PermissionService.set()` and writes the chosen preset through to both owning knob events. An open-turn switch appends immediately; an idle switch is overlaid in the response and anchored at the next turn start. Until that anchor it is memory-only and a crash reverts to the durable fold. ACP session modes are deliberately not modeled because config options are the forward protocol surface. Runtime model selection remains outside this decision; `AcpConfig.model` is connection-wide. The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. @@ -40,7 +40,7 @@ The precise supported and deferred protocol rows live in [`packages/ui/acp/acp-f **Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only. -**Represent sandbox and approval as ACP session modes** — rejected. They are independent composable settings, while a single current mode is mutually exclusive. ACP config options represent both without a cross-product and match the protocol's forward direction. +**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2. **Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index f853a123e1..9c2a506d71 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -23,7 +23,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in [the acp-agent example's sandbox variant](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. +The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-agent`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 4275ab9484..fa10a783a2 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -16,7 +16,7 @@ One seam, one per-platform chain of local backends, one consumer, and two levers ### How a deployment uses it -Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; [`examples/acp-agent`'s sandbox variant](../../../../examples/acp-agent/README.md) (`sandbox.cordis.yml`) is this composition, live: +Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed product path; [`examples/acp-agent`](../../../../examples/acp-agent/README.md) uses this composition by default: ```yaml - id: sandbox @@ -24,13 +24,17 @@ Three `cordis.yml` entries turn an unconfined coding agent into the sandboxed pr - id: bash name: '@deepseek-ai/dsh-bash-sandbox' # the confined executor, replacing dsh-bash-local behind ctx.bash config: - mode: read-only # the deployment default every session starts from + mode: workspace-write # the deployment default every session starts from workspaceRoot: !!js process.cwd() # the boundary workspace-write may write under - id: approval name: '@deepseek-ai/dsh-user-approval' # the escalation gate's channel (the approval RFC) + config: + policy: ask +- id: permission + name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs ``` -The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before, spawned through the wrapped argv the provider returns. Deleting the `sandbox` and `bash` entries and loading `@deepseek-ai/dsh-bash-local` instead is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text. +The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook commands, and background tasks run exactly as before, spawned through the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. @@ -120,7 +124,7 @@ Each owner exports the same three-piece kit: the event declaration, a pure fold **Visibility is deliberately asymmetric between the knobs.** The SANDBOX mode is stated nowhere and its switches are not narrated: a standing "you are read-only" declaration teaches the model to refuse preemptively (observed live: sessions where the model would not even attempt a write it could have escalated), while the denial marker already names the mode the command ran under at exactly the moment the boundary matters — behavior, not belief, carries the state, and a switch simply changes what the next command does. The APPROVAL policy keeps both layers, because its failure mode is the opposite: an auto-rejected ask under `'never'` returns "the user rejected …" wording no behavior can disambiguate, so the prompt states `'never'` (and ONLY `'never'` — an `'ask'` promise is unknowable without asking, and absence under a logged header is how the narrator reads `'ask'` back), and an `agent/pre-step` narrator injects at most one coalesced notice per policy switch: idle flip-flops collapse to one notice at the next turn's first step, a net-zero round trip to none, and a mid-turn change is narrated no later than the next step. Its "last told" is in-memory with a log-derived fallback (the folded header's system text parsed against the closed candidate sentence; LAST occurrence wins, so a persona quoting it cannot shadow the real section), so restarts lose nothing; attribution is positional (a knob event after the log's last `request/header*` reads `changed by the user`, a drift with no such event reads `changed by the operator/config`). -**The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. The bridge advertises one independent `select` per composable knob — `sandbox-mode` (category `mode`) iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with `currentValue` folded from each session's own log, in `session/new` and `session/load` responses. `session/set_config_option` validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract). +**The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options are the deployment's preset table, and its `currentValue` is `PermissionService.current()` over the session log plus composition defaults. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy and write through to both domain setters; a knob combination outside the table is reported as switch-away-only `custom`. `session/set_config_option` validates and switches through the permission service, then returns the complete refreshed state (the spec contract). **Anchoring: turn-enclosure is the commit boundary.** The turn-enclosure contract makes a bare between-turns append invalid (the JSONL backend treats a post-`turn/end` tail as crash garbage; dev invariants throw). A switch while a turn is open appends immediately — openness read from the LOG (last boundary event is `turn/start`), not `agent.status`, which stays `running` between queued turns. An idle switch is held on the bridge's session record and anchored at the next turn's `agent/prompt-submit` — inside the turn, before anything in it assembles or executes, last write per knob, and OUTSIDE any `session/event` emit (appending from inside that feed reorders events for later-registered listeners — a bug the dev invariants caught live). Until anchored, the switch exists only in bridge memory: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth, so the editor UI self-corrects rather than lies. @@ -133,9 +137,9 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine ### Testing - Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. -- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (the acp-agent example's `escalation.e2e.ts`): the real `sandbox.cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. +- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (the acp-agent example's `escalation.e2e.ts`): the real default `cordis.yml` tree advertises the one permission option, honors switches end to end, and rejects out-of-vocabulary values. - With-key e2e (`examples/acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded permission-switching arc as the pinned header of its class — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing one `request`→`yolo` preset switch (the `permission/preset` event written through to both knobs), the approval prompt-section delta and its "changed by the user" notice; and both recorded escalation branches over scripted `permissionAnswers` (grant runs under the granted `danger-full-access`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. +- Snapshot tier (`examples/acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded permission-switching arc as the pinned header of its class — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing one `workspace-write`→`danger-full-access` preset switch (the `permission/preset` event written through to both knobs), the approval prompt-section delta and its "changed by the user" notice; and both recorded escalation branches over scripted `permissionAnswers` (grant runs under the granted `danger-full-access`; rejection executes nothing and pins the fail-closed text). Snapshot mode starts the shared example tree at `danger-full-access` so established fixtures remain runner-independent; the switching and escalation inputs explicitly select `workspace-write` before exercising the policy path. Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the real-runner tiers above. ## Deferred phases @@ -169,7 +173,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/prompt-submit`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener. - **A standing prompt statement of the sandbox mode (+ a switch narrator)** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no". - **Track "last told" with its own bookkeeping events** — rejected: the `request/header*` fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store. -- **ACP session modes instead of config options** — rejected: one mode list cannot carry two orthogonal knobs; config options are the spec's designed surface and modes are slated for removal in ACP v2. +- **ACP session modes instead of config options** — rejected: the preset is already one deployment-defined config-option select, and modes are slated for removal in ACP v2. ## Consequences diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 35b250bbfb..46a4eaa27b 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -22,6 +22,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/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | | `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject | -| `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/escalation.e2e.ts` boots the sandbox variant's real tree (sandbox + approval + bridge) keyless: initialize + `session/new` | `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; `tests/escalation.e2e.ts` — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | +| `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/escalation.e2e.ts` boots the default tree (sandbox + approval + permission + bridge) keyless: initialize + `session/new` | `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; `tests/escalation.e2e.ts` — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | 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 431052f992..1319acd60b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,4 +33,4 @@ An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. -The **sandbox variant** (`sandbox.cordis.yml`) swaps the bash executor for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local) + [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) — the one-entry executor swap the `ctx.bash` capability seam exists for) and mounts [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval) — the composition where the approval loop is LIVE: a sandbox denial escalated by the model becomes a `session/request_permission` prompt in the editor, and "Allow once" runs exactly that command under the wider mode. Run with: `pnpm run demo:sandbox-acp` (needs `DEEPSEEK_API_KEY`; bwrap, a Landlock-enforcing kernel, or macOS for confined runs). +The default `cordis.yml` composes [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local), [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval), and [`@deepseek-ai/dsh-permission`](../packages/ui/permission). A capable client gets one `Permissions` select: `workspace-write` confines bash to the configured workspace and asks before a wider retry, while `danger-full-access` removes file confinement and disables approval prompts. A denied command can therefore surface a one-shot `session/request_permission` prompt in the editor; "Allow once" runs exactly that retry under the requested wider mode. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 31565a7b2e..6a48b1f5b0 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:code-mode acp` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). +This example is one leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge), the DeepSeek adapter, sandboxed bash, approval and permission services, subagent/workflow/todo tools, and the advisory `repeat-tool-guard`. ACP `session/new` creates agents on demand. The app package bakes in the no-stdout-logger cluster, so the leaf has no logger entry to get wrong by default. `demo:code-mode acp` boots the same tree through [`code-mode.cordis.yml`](code-mode.cordis.yml), collapsing the tool surface to `run_code` plus the generated TypeScript SDK (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). ## stdout is the protocol @@ -29,23 +29,22 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens; both the agent's bash tools and the `read`/`write`/`edit` filesystem tools resolve relative paths against that per-session workspace (see the per-session `cwd` note in `packages/ui/acp` and [the per-session cwd RFC](../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), so the server can be launched anywhere and each session still acts on its own project directory. +The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). Filesystem tools are omitted from the confined default because they execute in-process and do not ride the bash sandbox. ## Snapshot tests (record-once / replay-deterministic) This example is the home of the harness's **snapshot tests** — they boot this server as a real subprocess, drive it with a deterministic input script, and diff its normalized output against committed golden files. The model is made deterministic by `@deepseek-ai/dsh-llm-replay`, a function/namespace plugin that installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded **session JSONL** fixture (`/session.jsonl`) — so replay needs no API key. The fixture IS the persisted session log: its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`"; use `pnpm run test:snapshot:record` when the model transcript itself should change, and `pnpm run test:snapshot:refresh` when the committed model transcript is still the right mock input and only the current replay output/goldens need to be rewritten. The two failure modes not expressible as logged chunks — a pure throw before any chunk, and cancel/hang — use an optional `/replay.override.json` sidecar (a `ReplayEntry[]` that replaces the derived script). A scenario that needs the agent to operate on existing files ships an optional `/workspace/` directory — the harness copies its contents into the temp cwd before the run (see `workspace-edit`). See [the ACP snapshot tests RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) for the full design. -## The sandbox variant (`sandbox.cordis.yml`) +## Permissions and sandboxing -The same server with the bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) + [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) — the one-entry executor swap the `ctx.bash` capability seam exists for) plus [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/) — the composition where the approval loop is LIVE end to end: bash runs under `read-only`, a denial comes back as the structured marker, the model retries once with `sandbox_permissions` + `justification`, the ACP bridge's answerer turns that ask into a `session/request_permission` prompt in your editor, and "Allow once" runs exactly that command under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). Run it with `pnpm run demo:sandbox-acp`; Zed setup is the same as above with this command. +The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/), [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/), [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/), and [`@deepseek-ai/dsh-permission`](../../packages/ui/permission/). Bash starts in `workspace-write`; a denied operation returns a structured marker, and a retry with `sandbox_permissions` plus `justification` becomes a one-shot `session/request_permission` prompt in the editor. "Allow once" runs exactly that retry under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). -- **Every approval is one-shot** (`Allow once` / `Reject` — no `allow_always`: the harness has no grant storage yet), and a dismissed prompt or a rejected ask fails closed with its own error text; so does every ask when no editor is attached to answer. -- **One session config option is live** ([sandbox RFC § Per-session mode switching](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): a capable client shows a `Permissions` select per session (`request` = workspace-write + ask, `yolo` = danger-full-access + never — the `@deepseek-ai/dsh-permission` preset table), and a switch is one log-only `permission/preset` event written through to the two knob events, execution following the knobs; the sandbox mode is deliberately NOT stated in the prompt or narrated (the model learns the boundary from the denial marker — behavior, not belief), while an approval switch to `never` is stated and narrated; a resumed session reports its overrides back on `session/load`. -- **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -- **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the variant loads no filesystem tools: they would bypass the bash sandbox. +- **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined file access plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. +- **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. +- **The boundary is bash-only and config-fixed today**: in-process filesystem tools are omitted from the confined live default, while the sandbox workspace root remains the server's launch directory. -Variant tests, in this example's suites: `tests/escalation.e2e.ts` — keyless, it boots the real `sandbox.cordis.yml` through the Loader as an ACP subprocess, proves the whole tree (sandbox executor + approval service + bridge) initializes and opens a session, and drives the config options end to end; with a key and a usable runner, a scripted ACP client plays the human — the real model escalates a user-asserted denial, the client answers `allow-once`, and the retried write must land on disk. Four scenarios in `tests/acp.snapshot.ts` run against the variant's `sandbox.cordis.snapshot.yml` replay overlay under the `sandbox` header class: the keyless `config-options` exchange, the recorded `permission-switching` arc (that class's pinned header — one `request`→`yolo` preset switch, its approval prompt-section delta and "changed by the user" notice), and both recorded escalation branches (`session/request_permission` answered allow-once / reject-once). Replay re-executes every recorded bash call under the host's real runner — Seatbelt works out of the box on macOS; on Linux install bubblewrap first, exactly what ci.yml's snapshot lane does. No fixture carries a real denial: denial stderr is backend dialect and would pin a fixture to its recording platform (the rationale comment atop the suite file). +`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The snapshot suite uses the same tree: snapshot mode starts at `danger-full-access` so established fixtures remain runner-independent, while the permission-switching and escalation inputs explicitly select `workspace-write` before exercising that policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. ## MVP limitations -The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, and `additionalDirectories` and `mcpServers` are rejected. Permission prompts (`session/request_permission`) are wired through the approval seam; the MAIN tree composes no ask-producing policy, so its tools run with the executor's full authority — the sandbox variant above is the composition that exercises the live prompt. See `packages/ui/acp/README.md` for the full contract. +The bridge supports N concurrent sessions per connection, each with its own `cwd` (RFC 011). Prompts support ACP's baseline `text` and `resource_link` blocks only; `additionalDirectories` and `mcpServers` are rejected. See [`packages/ui/acp/README.md`](../../packages/ui/acp/README.md) for the full contract. diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index fb882a06a0..d5c3096666 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -10,8 +10,14 @@ 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"] + plugin_acp_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] + cfg --> plugin_acp_sandbox + plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash + plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] + cfg --> plugin_acp_approval + plugin_acp_permission["permission
@deepseek-ai/dsh-permission"] + cfg --> plugin_acp_permission 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"] @@ -54,7 +60,10 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `bash` | `@deepseek-ai/dsh-bash-local` | +| `sandbox` | `@deepseek-ai/dsh-sandbox-local` | +| `bash` | `@deepseek-ai/dsh-bash-sandbox` | +| `approval` | `@deepseek-ai/dsh-user-approval` | +| `permission` | `@deepseek-ai/dsh-permission` | | `acp-agent` | `@deepseek-ai/dsh-acp-agent` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index dc03ea6b03..42c8bb46f0 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,9 +1,9 @@ # The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config # (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek # run whose persisted log the snapshot harness harvests. The swappable DeepSeek -# adapter, local bash/filesystem executors, the ACP server app -# (@deepseek-ai/dsh-acp-agent), and the optional model-facing fs/subagent/todo -# tools loaded below. +# adapter, sandboxed bash executor, the ACP server app +# (@deepseek-ai/dsh-acp-agent), and the optional model-facing +# fs/subagent/todo tools loaded below. # # CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for # the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a @@ -23,12 +23,27 @@ - deepseek-v4-flash - deepseek-v4-pro -# Local bash executor for agent-core's tool-bash schema (one of several tool -# stacks in this tree: filesystem, subagent, and todo_write load below). +# The default composition confines bash to the workspace and asks before a +# wider retry. Snapshot runs select danger-full-access so the established +# scenarios remain runner-independent; DSH_PERMISSION_MODE provides the same +# explicit deployment/test override outside the snapshot harness. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + - id: bash - name: '@deepseek-ai/dsh-bash-local' + name: '@deepseek-ai/dsh-bash-sandbox' config: timeoutMs: 60000 + mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" + workspaceRoot: !!js process.cwd() + +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) === 'danger-full-access' ? 'never' : 'ask'" + +- id: permission + name: '@deepseek-ai/dsh-permission' # The ACP server app: the agent-core spine + JSONL persistence + the ACP bridge. # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it @@ -44,7 +59,7 @@ # loop resolves per session (every ACP session carries the client's cwd, # so the persona can state the workspace). persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. @@ -104,20 +119,22 @@ - id: repeat-tool-guard name: '@deepseek-ai/dsh-repeat-tool-guard' -# Filesystem capability stack: local provider, read-before-write/edit policy -# gate, then the model-facing read/write/edit tools. Relative filesystem paths -# resolve from the server launch cwd; the documented Zed setup launches this -# demo from the harness checkout with `pnpm --dir`. +# Filesystem tools do not ride the bash sandbox, so the confined default omits +# them. Snapshot tests and explicit danger-full-access launches keep the +# established filesystem scenarios by enabling the whole stack together. - id: fs-local name: '@deepseek-ai/dsh-fs-local' + disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" config: cwd: !!js process.cwd() - id: fs-policy name: '@deepseek-ai/dsh-fs-policy' + disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' + disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" # 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 diff --git a/examples/acp-agent/sandbox.cordis.snapshot.yml b/examples/acp-agent/sandbox.cordis.snapshot.yml deleted file mode 100644 index 04ed881b32..0000000000 --- a/examples/acp-agent/sandbox.cordis.snapshot.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Snapshot-test REPLAY overlay for the sandboxed composition: 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 sandbox provider, -# the confined bash executor, the approval seam, the app — IS the live tree. -# The sandbox provider probes for a platform runner per EXECUTION, not at -# boot, so a protocol-only scenario (session config options) replays on hosts -# with no runner at all. -# -# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay (the -# sibling-swap of whatever config path it was handed). The replay fixture -# path comes from $DSH_SNAPSHOT_FILE, set by the snapshot harness. stdout -# stays reserved for the ACP JSON-RPC protocol. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./sandbox.cordis.yml - patches: - # The name is an assertion, not an override: the include skips the patch - # (warning) when the id points at a different plugin, so this can never - # disable the wrong entry. - - 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/sandbox.cordis.yml b/examples/acp-agent/sandbox.cordis.yml deleted file mode 100644 index f83e5cce3f..0000000000 --- a/examples/acp-agent/sandbox.cordis.yml +++ /dev/null @@ -1,68 +0,0 @@ -# The SANDBOX VARIANT of the acp-agent example: the sandboxed coding agent -# served over the Agent Client Protocol, with the approval seam composed — -# the live approval composition (`pnpm run demo:sandbox-acp`). A sandbox denial escalated by the model -# (sandbox_permissions + justification) reaches the EDITOR as a -# session/request_permission prompt through the ACP bridge's answerer, and an -# "Allow once" runs exactly that command under the wider mode. -# -# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved -# for the ACP JSON-RPC protocol (a property of @deepseek-ai/dsh-acp-agent, -# same as the sibling cordis.yml). -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). - -# The DeepSeek adapter. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-flash - -# The sandbox stack: the platform-runner provider (bwrap → per-platform -# Landlock launcher → Seatbelt, functionally probed), then the confined bash executor. -# workspace-write is the default (the 'request' preset's bundle); the write -# boundary is workspaceRoot + the platform's temp area. NOTE: the -# workspace root is CONFIG-FIXED for the executor's lifetime (the launch dir -# here), while each ACP session has its own cwd — a per-session root is config- -# phase future work in the sandbox RFC. -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' -- id: bash - name: '@deepseek-ai/dsh-bash-sandbox' - config: - mode: workspace-write - workspaceRoot: !!js process.cwd() - -# The approval seam (ctx.approval — mechanism only, no config): with it -# mounted, the bash tool's escalation gate has a channel, and the ACP bridge -# inside dsh-acp-agent answers for the sessions it owns by prompting the -# editor. Without an editor attached nothing can answer, and every ask fails -# closed. -- id: approval - name: '@deepseek-ai/dsh-user-approval' - -# The user-facing permission presets: ONE `Permissions` select in the editor -# (request/yolo), each bundling a sandbox mode and an approval policy, written -# through to the two knob events. The defaults above (workspace-write + ask) -# reverse-map to `request`, so a fresh session starts there; a knob state -# outside the table derives the switch-away-only `custom` value. -- id: permission - name: '@deepseek-ai/dsh-permission' - -# The ACP server app: the agent-core spine + JSONL persistence + the ACP -# bridge (whose approval answerer completes the loop). -- id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' - config: - model: deepseek-v4-flash - # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness - # sets it (so a record run's logs land where the harness harvests them), - # else the local ./.sessions default. - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8ff012c2c3..9dee4d6d71 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -62,12 +62,13 @@ interface Spawned { function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { const child = spawn( process.execPath, - ['--import', tsxLoader, binScript, configPath], + ['--import', tsxLoader, binScript, '--config', configPath], { cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig, + DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), }, @@ -89,9 +90,8 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn return Promise.resolve() }, requestPermission(_params: RequestPermissionRequest): Promise { - // This example composes no ask-producing policy (no hooks), so the - // bridge never prompts here; answer cancelled (fail closed) if it ever - // does — an unexpected prompt must not grant anything. + // This suite selects danger-full-access (approval never), so the bridge + // never prompts here; answer cancelled if an unexpected ask arrives. return Promise.resolve({ outcome: { outcome: 'cancelled' } }) }, }) @@ -158,12 +158,13 @@ describe('acp-agent over real stdio (no key required)', () => { // A dummy key lets the deepseek adapter APPLY (it only checks the key is // present at boot, not valid — the key is used only on a real model call, // which this purity test never triggers). So this runs WITHOUT real creds. - const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], { + const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], { cwd: workdir, env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig, + DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(workdir, '.dsh'), DSH_AGENTS_HOME: join(workdir, '.agents'), }, diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f247e30891..2e81093fa4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -28,10 +28,6 @@ const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) -// The sandbox variant (its own composition, not an include patch: sandboxed -// bash executor + the approval seam over the same app spine). -const SANDBOX_CONFIG = fileURLToPath(new URL('../sandbox.cordis.yml', import.meta.url)) - function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { case undefined: @@ -141,41 +137,15 @@ const SCENARIOS: Scenario[] = [ // therefore pins its own class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, - // The SANDBOX variant (sandbox.cordis.yml: sandboxed bash + approval seam). - // Replay swaps only the MODEL for the recorded transcript — every bash call - // re-executes for real under the host's actual runner (Seatbelt on macOS, - // bwrap on Linux CI), so these recordings double as cross-backend - // confinement regression; their commands are limited to `cat`/`printf` - // shapes whose bytes are identical across those backends and across - // GNU/BSD userlands. Deliberately ABSENT: a scenario whose transcript - // carries a real sandbox DENIAL — the denied command's own stderr is the - // backend's dialect (EROFS/EACCES/EPERM phrasing), so such a fixture - // replays only on the platform that recorded it; the denial→marker path - // stays on dsh-tool-bash's unit tests and the real-kernel sandbox e2e legs, - // and the escalation scenarios sidestep it by having the USER assert the - // prior denial. config-options: the session config-option surface this - // composition adds (both advertised selects, the refreshed state every - // set_config_option answers with, both rejection shapes) — protocol-only, - // replays on runner-less hosts. permission-switching: the runtime - // preset-switch arc (request → yolo: one permission/preset event written - // through to both knobs) and NECESSARILY this class's pinned-header - // scenario (yolo's approval=never rewrites the prompt section; the - // resulting request/header-delta is legal only in the pinning scenario) — - // the pin commits the full sandbox header (persona, tool schemas WITH the - // escalation fields) plus the approval delta and its "changed by the user" - // notice; the sandbox half of the bundle stays deliberately silent (the - // visibility asymmetry) and is pinned as its stamped knob event. - // escalation-approved/rejected: the approval wire end-to-end under the - // default request preset (workspace-write/ask) — the escalating call - // (danger-full-access for a user-asserted outside-workspace denial) - // streams, session/request_permission attaches to it, and the scripted - // answer drives each branch (approved runs under the granted mode and - // self-cleans its /tmp target; rejected executes nothing, failing with - // the deterministic text). - { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, - { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, - { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, - { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox', configPath: SANDBOX_CONFIG }, + // The default tree owns the single Permissions select. Snapshot mode starts + // in danger-full-access so established fixtures stay runner-independent; + // these policy scenarios switch to workspace-write in their input scripts. + // Real-kernel confinement remains in escalation.e2e.ts and the sandbox + // packages' e2e suites. + { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox' }, + { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox' }, + { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, + { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, ] defineAcpSnapshotSuite({ diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 9c99f53306..2137efac00 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -17,9 +17,9 @@ import { } from '@agentclientprotocol/sdk' /** - * The sandbox variant (`sandbox.cordis.yml`) end to end. + * The default ACP composition (`cordis.yml`) end to end. * - * Keyless smoke: boot the REAL `sandbox.cordis.yml` through the `dsh-acp-agent` bin as + * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as * an ACP subprocess and drive initialize + session/new — the real-Loader-path * guard (postmortem 0001) for THIS tree's export shapes, which now include the * sandbox executor AND the approval service. No prompt is sent, so neither the @@ -36,7 +36,7 @@ import { */ const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../sandbox.cordis.yml', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The subprocess runs from a temp cwd OUTSIDE the repo; point tsx at the repo // tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). @@ -65,10 +65,10 @@ interface Spawned { } /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function spawnSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { +function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { const child = spawn( process.execPath, - ['--import', tsxLoader, binScript, configPath], + ['--import', tsxLoader, binScript, '--config', configPath], { cwd, // A dummy key lets the deepseek adapter boot keyless (presence-checked at @@ -116,10 +116,10 @@ afterEach(async () => { workdir = undefined }) -describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader)', () => { +describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = spawnAcpAgent(workdir, 'reject-once') const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same @@ -132,7 +132,7 @@ describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader) it('advertises the Permissions select and honors a switch end to end (no key, no model)', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = spawnAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) // This tree composes the permission presets over bash-sandbox + approval → @@ -140,19 +140,19 @@ describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader) const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) - .toEqual([['permission', 'request']]) + .toEqual([['permission', 'workspace-write']]) // A switch responds with the COMPLETE refreshed state (the spec contract), // and the new current survives in the response of a second switch. - const afterYolo = await client.setSessionConfigOption({ - sessionId: created.sessionId, configId: 'permission', value: 'yolo', + const afterFullAccess = await client.setSessionConfigOption({ + sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access', }) - expect(afterYolo.configOptions?.find(option => option.id === 'permission')) - .toMatchObject({ currentValue: 'yolo' }) + expect(afterFullAccess.configOptions?.find(option => option.id === 'permission')) + .toMatchObject({ currentValue: 'danger-full-access' }) const again = await client.setSessionConfigOption({ - sessionId: created.sessionId, configId: 'permission', value: 'yolo', + sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access', }) expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) - .toEqual([['permission', 'yolo']]) + .toEqual([['permission', 'danger-full-access']]) // An out-of-vocabulary value is a protocol error, never a silent default. await expect(client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'plan', @@ -160,10 +160,10 @@ describe('sandbox variant keyless smoke (real sandbox.cordis.yml via the Loader) }, 30_000) }) -describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2e: the live approval loop', () => { +describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'allow-once') + spawned = spawnAcpAgent(workdir, 'allow-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -192,7 +192,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox variant e2 it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = spawnAcpAgent(workdir, 'reject-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index bdb800186a..43e5034eb3 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -49,8 +49,8 @@ interface Spawned { 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'] }, + ['--import', tsxLoader, binScript, '--config', configPath], + { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access' }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 1eabba53b3..cdc09c92c0 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 7787ed2ab1..79d9b94ad9 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index bb74382b22..7155b1c2a6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index 2e9174f026..72b41e8d89 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index e70cfcce44..6e8f157ace 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -5,14 +5,11 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. ## Writing code for run_code @@ -28,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; @@ -40,6 +37,10 @@ declare const tools: { workdir?: string; /** Run in the background and return a task id immediately. No timeout applies. */ run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; }): Promise; /** Ask the executor to kill a running background bash task by task id. */ bash_kill(args: { @@ -66,26 +67,6 @@ declare const tools: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; }): Promise; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { - /** Path to edit, resolved by the filesystem backend. */ - file_path: string; - /** Literal text to replace. Must match exactly. */ - old_string: string; - /** Literal replacement text. Use an empty string to delete the match. */ - new_string: string; - /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ - replace_all?: boolean; - }): Promise; - /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { - /** Path to read, resolved by the filesystem backend. */ - file_path: string; - /** 1-based first line to return. Defaults to 1. */ - offset?: number; - /** Maximum number of lines to return. Defaults to 2000. */ - limit?: number; - }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -140,12 +121,5 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; - /** Create or fully replace a UTF-8 text file. */ - write(args: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index b49097188e..66cdecedfb 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index e54775b7ff..f5bf912125 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 5dd8547aa8..98b4f97fee 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -5,14 +5,11 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. ## Writing code for run_code @@ -28,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; @@ -40,6 +37,10 @@ declare const tools: { workdir?: string; /** Run in the background and return a task id immediately. No timeout applies. */ run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; }): Promise; /** Ask the executor to kill a running background bash task by task id. */ bash_kill(args: { @@ -51,26 +52,6 @@ declare const tools: { /** Task id returned by the bash tool. */ task_id: string; }): Promise; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { - /** Path to edit, resolved by the filesystem backend. */ - file_path: string; - /** Literal text to replace. Must match exactly. */ - old_string: string; - /** Literal replacement text. Use an empty string to delete the match. */ - new_string: string; - /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ - replace_all?: boolean; - }): Promise; - /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { - /** Path to read, resolved by the filesystem backend. */ - file_path: string; - /** 1-based first line to return. Defaults to 1. */ - offset?: number; - /** Maximum number of lines to return. Defaults to 2000. */ - limit?: number; - }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -125,12 +106,5 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; - /** Create or fully replace a UTF-8 text file. */ - write(args: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index 3400223f4d..ea8a9c2bb7 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,4 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index 97610ed1c0..c0b2346444 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 5dd8547aa8..98b4f97fee 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -5,14 +5,11 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. ## Writing code for run_code @@ -28,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; @@ -40,6 +37,10 @@ declare const tools: { workdir?: string; /** Run in the background and return a task id immediately. No timeout applies. */ run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; }): Promise; /** Ask the executor to kill a running background bash task by task id. */ bash_kill(args: { @@ -51,26 +52,6 @@ declare const tools: { /** Task id returned by the bash tool. */ task_id: string; }): Promise; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { - /** Path to edit, resolved by the filesystem backend. */ - file_path: string; - /** Literal text to replace. Must match exactly. */ - old_string: string; - /** Literal replacement text. Use an empty string to delete the match. */ - new_string: string; - /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ - replace_all?: boolean; - }): Promise; - /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { - /** Path to read, resolved by the filesystem backend. */ - file_path: string; - /** 1-based first line to return. Defaults to 1. */ - offset?: number; - /** Maximum number of lines to return. Defaults to 2000. */ - limit?: number; - }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -125,12 +106,5 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; - /** Create or fully replace a UTF-8 text file. */ - write(args: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/config-options/input.json b/examples/acp-agent/tests/snapshots/config-options/input.json index f18af9e7e9..900367cea8 100644 --- a/examples/acp-agent/tests/snapshots/config-options/input.json +++ b/examples/acp-agent/tests/snapshots/config-options/input.json @@ -9,12 +9,12 @@ { "op": "setConfigOption", "configId": "permission", - "value": "yolo" + "value": "workspace-write" }, { "op": "setConfigOption", "configId": "permission", - "value": "request" + "value": "danger-full-access" }, { "op": "setConfigOptionExpectError", diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index f68a1a2165..0bcb0245d0 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"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}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"yolo","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 8515dacf5a..63d9032ca2 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/input.json b/examples/acp-agent/tests/snapshots/escalation-approved/input.json index 226171ecfc..99f9f8821b 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/input.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/input.json @@ -6,6 +6,11 @@ { "op": "newSession" }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "workspace-write" + }, { "op": "prompt", "text": "The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop." diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 0070d9d43f..7dff360513 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,185 +1,188 @@ {"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783860675271,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783860675275,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783860675275,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783860676324,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783860676324,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783860676434,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":15,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":16,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":19,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} -{"type":"assistant/chunk","seq":21,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":24,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} -{"type":"assistant/chunk","seq":26,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":27,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":28,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":29,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} -{"type":"assistant/chunk","seq":30,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":31,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":32,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":33,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":34,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":35,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":43,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":48,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":49,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":50,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":51,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":54,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":55,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":59,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":60,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":62,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":65,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":69,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":71,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":77,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":78,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":79,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":83,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":85,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":87,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":88,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":89,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":90,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":91,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":96,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":100,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":102,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":105,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":107,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} +{"type":"permission/preset","seq":1,"time":1783962245380,"data":{"preset":"workspace-write"}} +{"type":"bash/sandbox-mode","seq":2,"time":1783962245380,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1783962245380,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1783962245380,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1783962245382,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1783962245382,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1783860676464,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1783860676465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783860676499,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783860676522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":18,"time":1783860676525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":19,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":20,"time":1783860676553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":21,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":22,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":23,"time":1783860676554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" danger"}}} +{"type":"assistant/chunk","seq":24,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":25,"time":1783860676583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":26,"time":1783860676611,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1783860676639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":28,"time":1783860676640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":29,"time":1783860676672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":30,"time":1783860676673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":31,"time":1783860676705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":32,"time":1783860676728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justified"}}} +{"type":"assistant/chunk","seq":33,"time":1783860676787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":34,"time":1783860676788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":35,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783860676816,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783860676845,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":41,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783860676846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":45,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":46,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":47,"time":1783860676878,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":48,"time":1783860676879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":49,"time":1783860676909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":50,"time":1783860676911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":51,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":52,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":53,"time":1783860676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":54,"time":1783860676939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":55,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":56,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":57,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":58,"time":1783860676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":59,"time":1783860676967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":60,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":61,"time":1783860676969,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":62,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":63,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":64,"time":1783860676970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":65,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":66,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":67,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":68,"time":1783860677003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":69,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":70,"time":1783860677004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":71,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":72,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":73,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":74,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":75,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":76,"time":1783860677026,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":77,"time":1783860677055,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":78,"time":1783860677085,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":79,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":80,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":82,"time":1783860677087,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":83,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":84,"time":1783860677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":85,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":86,"time":1783860677116,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1783860677146,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":88,"time":1783860677147,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":89,"time":1783860677148,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":90,"time":1783860677174,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":91,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":92,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":93,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":94,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":95,"time":1783860677202,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":97,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":98,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":99,"time":1783860677238,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":100,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1783860677275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":102,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783860677276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":104,"time":1783860677292,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":105,"time":1783860677293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":106,"time":1783860677320,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":", "}}} {"type":"assistant/chunk","seq":108,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":110,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":116,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":120,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":121,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} -{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":127,"time":1783860677497,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126],"surfaceOp":"append"} -{"type":"tool/call","seq":128,"time":1783860677498,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":129,"time":1783860677500,"data":{"id":"fccb92c4-eaa5-49cf-9ccb-9c9b7b90a169","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":130,"time":1783860677503,"data":{"id":"fccb92c4-eaa5-49cf-9ccb-9c9b7b90a169","outcome":"allowed-once"}} -{"type":"tool/result","seq":131,"time":1783860677588,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[128],"surfaceOp":"append"} -{"type":"step/end","seq":132,"time":1783860677589,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":133,"time":1783860677589,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":134,"time":1783860678646,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":135,"time":1783860678646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":136,"time":1783860678781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":137,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} -{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":139,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":140,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} -{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":142,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":145,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":146,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":147,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} -{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":150,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} -{"type":"assistant/chunk","seq":151,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} -{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} -{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} -{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} -{"type":"assistant/chunk","seq":156,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":157,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":158,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":161,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":162,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":165,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":166,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":167,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":169,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":171,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":172,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":177,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} -{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} -{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":181,"time":1783860679138,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":182,"time":1783860679138,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":183,"time":1783860679138,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":109,"time":1783860677321,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":110,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":111,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":112,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":113,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":115,"time":1783860677349,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":116,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":117,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":118,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":119,"time":1783860677388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":120,"time":1783860677492,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":121,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":122,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":123,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":124,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":126,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"type":"assistant/chunk","seq":127,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":128,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} +{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"d409f075-74f1-4637-9e13-6e80d7b6f6ff","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"d409f075-74f1-4637-9e13-6e80d7b6f6ff","outcome":"allowed-once"}} +{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} +{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":137,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":138,"time":1783860678811,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":139,"time":1783860678833,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":140,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":141,"time":1783860678834,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":142,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":143,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wrote"}}} +{"type":"assistant/chunk","seq":144,"time":1783860678868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":145,"time":1783860678904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":146,"time":1783860678905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":147,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":148,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":149,"time":1783860678926,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":150,"time":1783860678975,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":151,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"output"}}} +{"type":"assistant/chunk","seq":152,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":153,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":154,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"cal"}}} +{"type":"assistant/chunk","seq":155,"time":1783860678976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ated"}}} +{"type":"assistant/chunk","seq":156,"time":1783860678999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"),"}}} +{"type":"assistant/chunk","seq":157,"time":1783860679001,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":158,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" removed"}}} +{"type":"assistant/chunk","seq":159,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":160,"time":1783860679002,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":161,"time":1783860679016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":162,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":163,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":164,"time":1783860679017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":165,"time":1783860679046,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":166,"time":1783860679047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":167,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":168,"time":1783860679048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":169,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":170,"time":1783860679079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":171,"time":1783860679103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":172,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":173,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":174,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":175,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":176,"time":1783860679136,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":177,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":178,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":179,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":180,"time":1783860679137,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"type":"assistant/chunk","seq":181,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":182,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":183,"time":1783962245402,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":184,"time":1783962245402,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":27,"outputTokens":42,"cacheReadTokens":1664,"reasoningTokens":39}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":185,"time":1783962245402,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":186,"time":1783962245402,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index c3a85270f5..d8074c0757 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,5 +1,6 @@ {"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}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -72,4 +73,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":"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"}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/input.json b/examples/acp-agent/tests/snapshots/escalation-rejected/input.json index 89218b3e30..83b9d8aab5 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/input.json +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/input.json @@ -6,6 +6,11 @@ { "op": "newSession" }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "workspace-write" + }, { "op": "prompt", "text": "The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it \u2014 explain in one short sentence and stop." diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 15b0a24274..55cad3689d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,212 +1,215 @@ {"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783860679477,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783860679478,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783860679478,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783860680630,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783860680630,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783860680750,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":14,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":16,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} -{"type":"assistant/chunk","seq":17,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":19,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":21,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} -{"type":"assistant/chunk","seq":22,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} -{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} -{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} -{"type":"assistant/chunk","seq":27,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":28,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":30,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":31,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} -{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":34,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":36,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} -{"type":"assistant/chunk","seq":37,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":41,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} -{"type":"assistant/chunk","seq":42,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} -{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} -{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} -{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":48,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":50,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":51,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":53,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":57,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":62,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} -{"type":"assistant/chunk","seq":64,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":70,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":71,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":74,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":75,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":79,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":83,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":84,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":85,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} -{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":91,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} -{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} -{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} -{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} -{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} -{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":97,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":98,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":99,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":103,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":106,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} -{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} -{"type":"assistant/chunk","seq":108,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":109,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} -{"type":"assistant/chunk","seq":110,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} -{"type":"assistant/chunk","seq":111,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} -{"type":"assistant/chunk","seq":112,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} -{"type":"assistant/chunk","seq":113,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} -{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":115,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} -{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} -{"type":"assistant/chunk","seq":119,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} -{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} -{"type":"assistant/chunk","seq":121,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":123,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":124,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} -{"type":"assistant/chunk","seq":125,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} -{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} -{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":128,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} -{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} +{"type":"permission/preset","seq":1,"time":1783962246267,"data":{"preset":"workspace-write"}} +{"type":"bash/sandbox-mode","seq":2,"time":1783962246267,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1783962246267,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1783962246267,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1783962246269,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1783962246269,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1783860680779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1783860680782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1783860680830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783860680831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783860680859,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":16,"time":1783860680868,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":18,"time":1783860680871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":19,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":20,"time":1783860680872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":21,"time":1783860680902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":22,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":23,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":24,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":25,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":26,"time":1783860680903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":27,"time":1783860680937,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"danger"}}} +{"type":"assistant/chunk","seq":28,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-full"}}} +{"type":"assistant/chunk","seq":29,"time":1783860680938,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-access"}}} +{"type":"assistant/chunk","seq":30,"time":1783860680956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":31,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":32,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":33,"time":1783860680958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":34,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" justification"}}} +{"type":"assistant/chunk","seq":35,"time":1783860680985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783860681022,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":37,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":38,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":39,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":40,"time":1783860681024,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783860681043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":42,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":43,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":44,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":45,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":46,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":47,"time":1783860681091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":48,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":49,"time":1783860681107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":50,"time":1783860681108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":51,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1783860681138,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":53,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":54,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":55,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":56,"time":1783860681251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":57,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":58,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":59,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783860681252,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":61,"time":1783860681280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783860681281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":65,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":66,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":67,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":68,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":69,"time":1783860681324,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":70,"time":1783860681342,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":71,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":72,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":73,"time":1783860681343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":74,"time":1783860681368,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":75,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":76,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":77,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":78,"time":1783860681371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":79,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":80,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":81,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":82,"time":1783860681400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":83,"time":1783860681401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":84,"time":1783860681402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":85,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":86,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":87,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":88,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":89,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":90,"time":1783860681432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":91,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" rm"}}} +{"type":"assistant/chunk","seq":92,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":93,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":94,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"/d"}}} +{"type":"assistant/chunk","seq":95,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sh"}}} +{"type":"assistant/chunk","seq":96,"time":1783860681456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-es"}}} +{"type":"assistant/chunk","seq":97,"time":1783860681485,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":98,"time":1783860681519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":99,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":100,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":102,"time":1783860681521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783860681542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":104,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783860681545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":106,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783860681573,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":108,"time":1783860681574,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":109,"time":1783860681605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" /"}}} +{"type":"assistant/chunk","seq":110,"time":1783860681608,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"tmp"}}} +{"type":"assistant/chunk","seq":111,"time":1783860681630,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":112,"time":1783860681632,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":113,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":114,"time":1783860681661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":115,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" clean"}}} +{"type":"assistant/chunk","seq":116,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" up"}}} +{"type":"assistant/chunk","seq":117,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783860681693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":119,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1783860681725,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":121,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":122,"time":1783860681726,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":123,"time":1783860681751,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":124,"time":1783860681753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":126,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":127,"time":1783860681754,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":128,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":129,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":130,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":131,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":", "}}} {"type":"assistant/chunk","seq":132,"time":1783860681813,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":133,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":134,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":135,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} -{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} -{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} -{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":139,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} -{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} -{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} -{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} -{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":144,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} -{"type":"assistant/chunk","seq":145,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":146,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":147,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} -{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} -{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":151,"time":1783860681971,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,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],"surfaceOp":"append"} -{"type":"tool/call","seq":152,"time":1783860681971,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":153,"time":1783860681972,"data":{"id":"0d14d42a-fdd8-4a2c-9618-78c4e5e65cb0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":154,"time":1783860681975,"data":{"id":"0d14d42a-fdd8-4a2c-9618-78c4e5e65cb0","outcome":"rejected"}} -{"type":"tool/result","seq":155,"time":1783860681976,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[152],"surfaceOp":"append"} -{"type":"step/end","seq":156,"time":1783860681976,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":157,"time":1783860681977,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":158,"time":1783860682965,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":159,"time":1783860682965,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":160,"time":1783860683121,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":162,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} -{"type":"assistant/chunk","seq":164,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} -{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":167,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":168,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} -{"type":"assistant/chunk","seq":172,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} -{"type":"assistant/chunk","seq":173,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":174,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} -{"type":"assistant/chunk","seq":175,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} -{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} -{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} -{"type":"assistant/chunk","seq":180,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":181,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} -{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":183,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} -{"type":"assistant/chunk","seq":186,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":189,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":191,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} -{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":193,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":194,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} -{"type":"assistant/chunk","seq":195,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} -{"type":"assistant/chunk","seq":196,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} -{"type":"assistant/chunk","seq":198,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":199,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} -{"type":"assistant/chunk","seq":201,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} -{"type":"assistant/chunk","seq":202,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} -{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":204,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} -{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} -{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":208,"time":1783860683506,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":209,"time":1783860683506,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":210,"time":1783860683506,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":133,"time":1783860681815,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":134,"time":1783860681840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":135,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":136,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":137,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1783860681842,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":139,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":140,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":141,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":142,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":143,"time":1783860681870,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":144,"time":1783860681872,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":145,"time":1783860681901,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" outside"}}} +{"type":"assistant/chunk","seq":146,"time":1783860681903,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":147,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":" workspace"}}} +{"type":"assistant/chunk","seq":148,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":149,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":150,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":151,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}}} +{"type":"assistant/chunk","seq":152,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}}}} +{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,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],"surfaceOp":"append"} +{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e592115c-e915-4010-9bf5-cc5e7bb6f8bc","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e592115c-e915-4010-9bf5-cc5e7bb6f8bc","outcome":"rejected"}} +{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} +{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":162,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":163,"time":1783860683141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":164,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":165,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":166,"time":1783860683170,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} +{"type":"assistant/chunk","seq":167,"time":1783860683205,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":168,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" As"}}} +{"type":"assistant/chunk","seq":169,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":170,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":171,"time":1783860683208,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":172,"time":1783860683230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":173,"time":1783860683232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":174,"time":1783860683233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":175,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":176,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":177,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":178,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":179,"time":1783860683260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":180,"time":1783860683294,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" around"}}} +{"type":"assistant/chunk","seq":181,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":182,"time":1783860683296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" —"}}} +{"type":"assistant/chunk","seq":183,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":184,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explain"}}} +{"type":"assistant/chunk","seq":185,"time":1783860683317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":186,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":187,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":188,"time":1783860683318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":189,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":190,"time":1783860683347,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":191,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":192,"time":1783860683349,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":193,"time":1783860683375,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":194,"time":1783860683376,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" user"}}} +{"type":"assistant/chunk","seq":195,"time":1783860683409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":196,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":197,"time":1783860683410,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" permission"}}} +{"type":"assistant/chunk","seq":198,"time":1783860683435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" escalation"}}} +{"type":"assistant/chunk","seq":199,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":200,"time":1783860683437,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":201,"time":1783860683462,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":202,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":203,"time":1783860683464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":204,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" be"}}} +{"type":"assistant/chunk","seq":205,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" run"}}} +{"type":"assistant/chunk","seq":206,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":207,"time":1783860683506,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"type":"assistant/chunk","seq":208,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"type":"assistant/chunk","seq":209,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":210,"time":1783962246279,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":211,"time":1783962246279,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."},{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}],"usage":{"inputTokens":69,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":212,"time":1783962246279,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":213,"time":1783962246279,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index 4d24b7b9ad..c14b05f06a 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,5 +1,6 @@ {"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}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -95,4 +96,4 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" be"}}}} {"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":"."}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 90f009946d..723282d1a8 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -69,7 +69,7 @@ {"type":"assistant/chunk","seq":67,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":68,"time":1783352086059,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"/tmp/acp-snap-cwd-0BxHdV/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","seq":71,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":72,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":73,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -129,7 +129,7 @@ {"type":"assistant/chunk","seq":127,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[129],"surfaceOp":"append"} +{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":131,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":132,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":133,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 e585a34fbe..5bc88537ce 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -46,8 +46,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","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_S6krdIDHoSCGWv7WnXX97617","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":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} {"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"}}}} @@ -66,8 +66,8 @@ {"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_vOytneZ0XpsLslEEJAxR6398","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_vOytneZ0XpsLslEEJAxR6398","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":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt","old_string":"DEBUG","new_string":"RELEASE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} {"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":" The"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 802120fd9c..8a500475c0 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -77,7 +77,7 @@ {"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":76,"time":1783611703972,"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, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} {"type":"tool/call","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","seq":79,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":80,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":81,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -144,7 +144,7 @@ {"type":"assistant/chunk","seq":142,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":143,"time":1783611705573,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} +{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","seq":146,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":147,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":148,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -225,7 +225,7 @@ {"type":"assistant/chunk","seq":223,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":224,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[225],"surfaceOp":"append"} +{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[225],"surfaceOp":"append"} {"type":"step/end","seq":227,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":228,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":229,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 index dfe2cd6ec5..864cc3de9f 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -36,8 +36,8 @@ {"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_x0zlnXl5JOxLrAYL9y7P0119","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_x0zlnXl5JOxLrAYL9y7P0119","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":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -82,8 +82,8 @@ {"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":"tool_call","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","title":"Read settings.txt","kind":"read","status":"in_progress","locations":[{"path":"settings.txt","line":1}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\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_tfr0gdIclW92oIEgi7NF0891","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"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":" contains"}}}} @@ -124,8 +124,8 @@ {"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":" work"}}}} {"jsonrpc":"2.0","method":"session/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_GVknJu2tksKkP4lALCwh0926","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_GVknJu2tksKkP4lALCwh0926","status":"completed","content":[{"type":"diff","path":"settings.txt","oldText":"color: blue","newText":"color: green"}],"title":"Edit settings.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replacement"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} 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 becc503c65..f22aba96b2 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -91,7 +91,7 @@ {"type":"assistant/chunk","seq":89,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":90,"time":1783352101348,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the read tool 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.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89],"surfaceOp":"append"} {"type":"tool/call","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"/tmp/acp-snap-cwd-N9HCkt/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":[91],"surfaceOp":"append"} +{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[91],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":95,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 e265e7d51d..c283736334 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,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -56,8 +56,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","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_GIZwZS9a7vhWTFCIc7Z35497","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_GIZwZS9a7vhWTFCIc7Z35497","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"big.txt","offset":5,"limit":4}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"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":" read"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 3af4b2ac61..d91d10d39a 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1783352073708,"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_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[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":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"/tmp/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":56,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":57,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 6efc6f6d67..4f2973bcc5 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -29,8 +29,8 @@ {"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_hHPZCcivsIkXAGS9jTGy8417","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_hHPZCcivsIkXAGS9jTGy8417","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_hHPZCcivsIkXAGS9jTGy8417","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"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":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} 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 index e9dcb4c77d..6ab374dc0a 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 47627ae7a5..0233abbe39 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -65,7 +65,7 @@ {"type":"assistant/chunk","seq":63,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":64,"time":1783352093617,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":68,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":69,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -114,7 +114,7 @@ {"type":"assistant/chunk","seq":112,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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":"tool/call","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[114],"surfaceOp":"append"} +{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","seq":116,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":117,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":118,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 6eec0d1f76..03c77cae98 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,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -42,8 +42,8 @@ {"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_n4eRJuGoxNR07svgNtk82243","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_n4eRJuGoxNR07svgNtk82243","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":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"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":" contains"}}}} @@ -61,8 +61,8 @@ {"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_N23EvXjDo4c8enyWpIUq4043","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_N23EvXjDo4c8enyWpIUq4043","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":"tool_call","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt","content":"replaced"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 7e4b2dda01..6170af99a5 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -62,7 +62,7 @@ {"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":61,"time":1783352079888,"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 \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[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":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":66,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 6f3dd77daf..1e68a6b90a 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -30,8 +30,8 @@ {"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_APMUCJJm9lrTSlVbg6dB0185","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_APMUCJJm9lrTSlVbg6dB0185","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":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"notes.txt","content":"hello world"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index ad3f94a046..e39f3694a8 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,2 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} 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 index 5b72aeced2..3e414cacab 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 81b44b06cc..be13a31a84 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 46a5e135a6..3933a939ba 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,59 +55,61 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"tool/result","seq":56,"time":1783352172573,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: bash requires manual approval in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} -{"type":"step/end","seq":57,"time":1783352172574,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":58,"time":1783352172574,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":59,"time":1783352173493,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":60,"time":1783352173493,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":62,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":64,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} -{"type":"assistant/chunk","seq":65,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} -{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} -{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":68,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} -{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} -{"type":"assistant/chunk","seq":70,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":72,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":73,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} -{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":76,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":78,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":80,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":81,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":85,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":86,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":87,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":89,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":90,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":92,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} -{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} -{"type":"assistant/chunk","seq":95,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":96,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} -{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} -{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} -{"type":"assistant/chunk","seq":101,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} -{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} -{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} -{"type":"assistant/chunk","seq":105,"time":1783352173964,"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. I'll report this verbatim."}}}} -{"type":"assistant/chunk","seq":106,"time":1783352173964,"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":107,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":108,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":109,"time":1783352173964,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"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,105,106,107,108],"surfaceOp":"append"} -{"type":"step/end","seq":110,"time":1783352173964,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":111,"time":1783352173965,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"cdd11a3a-c721-4d08-8255-732218775c33","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"cdd11a3a-c721-4d08-8255-732218775c33","outcome":"rejected"}} +{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":62,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":63,"time":1783352173615,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":64,"time":1783352173644,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":65,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":66,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":67,"time":1783352173645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":68,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} +{"type":"assistant/chunk","seq":69,"time":1783352173669,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":70,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":71,"time":1783352173670,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} +{"type":"assistant/chunk","seq":72,"time":1783352173698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":73,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":74,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":75,"time":1783352173699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":76,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":77,"time":1783352173728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":79,"time":1783352173729,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":80,"time":1783352173755,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":81,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":82,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":83,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":84,"time":1783352173756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":85,"time":1783352173789,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":86,"time":1783352173823,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":87,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":88,"time":1783352173854,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1783352173879,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} +{"type":"assistant/chunk","seq":90,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} +{"type":"assistant/chunk","seq":91,"time":1783352173906,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":92,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":93,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":94,"time":1783352173907,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":95,"time":1783352173932,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":96,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":97,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":98,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":99,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":100,"time":1783352173933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} +{"type":"assistant/chunk","seq":101,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":102,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":103,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":104,"time":1783352173963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":105,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":106,"time":1783352173964,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":107,"time":1783352173964,"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. I'll report this verbatim."}}}} +{"type":"assistant/chunk","seq":108,"time":1783352173964,"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":109,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":110,"time":1783962235816,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":111,"time":1783962235816,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"usage":{"inputTokens":166,"outputTokens":45,"cacheReadTokens":2816,"reasoningTokens":22}},"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,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} +{"type":"step/end","seq":112,"time":1783962235816,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":113,"time":1783962235816,"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 index 15abe487af..ce7acef1f3 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -18,7 +18,7 @@ {"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_6k0oGSliVHxGSgqBmMEO4311","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_6k0oGSliVHxGSgqBmMEO4311","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":"tool_call_update","toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: the user rejected tool \"bash\"\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"}}}} 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 index cd59430ee0..d31dd2c810 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 6f6e5b662f..19dd593b09 100644 --- 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 @@ -1,3 +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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} 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 index 567d3d98cb..19ae540ab1 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index ff6e09bff9..d76b8537f0 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index fddecc08c6..f62af79405 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 21af4a0783..38a3055a16 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 878b9777e8..93ff9f89fc 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 6f6e5b662f..19dd593b09 100644 --- 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 @@ -1,3 +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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} 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 index 96a03f233e..a2028ef4b8 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} 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 index 88ce7ce421..e628431ee3 100644 --- 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 @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 22bb6757fd..f288d7a05f 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/input.json b/examples/acp-agent/tests/snapshots/permission-switching/input.json index 621ab955cd..9adb6b1562 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/input.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/input.json @@ -6,6 +6,11 @@ { "op": "newSession" }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "workspace-write" + }, { "op": "prompt", "text": "Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop." @@ -13,7 +18,7 @@ { "op": "setConfigOption", "configId": "permission", - "value": "yolo" + "value": "danger-full-access" }, { "op": "prompt", diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index c47b2c4cec..e1438965b7 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -1,235 +1,238 @@ {"type":"session","version":0,"id":"df041acb-2f14-4d5f-b6e2-2fb6b9eb6427","createdAt":1783860666204,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-4oJKT4"} {"type":"turn/start","seq":0,"time":1783860666206,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783860666206,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783860666208,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783860666208,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783860667254,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783860667255,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783860667420,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":13,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":14,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":15,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":16,"time":1783860667479,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":17,"time":1783860667501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":18,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":19,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":20,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":21,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":22,"time":1783860667595,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":23,"time":1783860667596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":24,"time":1783860667623,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":25,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":26,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":27,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":28,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":29,"time":1783860667656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":30,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"printf"}}} -{"type":"assistant/chunk","seq":31,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":32,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"before"}}} -{"type":"assistant/chunk","seq":33,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\\\\n"}}} -{"type":"assistant/chunk","seq":34,"time":1783860667686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":35,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" >"}}} -{"type":"assistant/chunk","seq":36,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":37,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":38,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" &&"}}} -{"type":"assistant/chunk","seq":39,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" cat"}}} -{"type":"assistant/chunk","seq":40,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":41,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":42,"time":1783860667711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783860667738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":44,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"Write"}}} -{"type":"assistant/chunk","seq":50,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":51,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" then"}}} -{"type":"assistant/chunk","seq":52,"time":1783860667834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" read"}}} -{"type":"assistant/chunk","seq":53,"time":1783860667835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":54,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":55,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783860667889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":57,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":58,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":60,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1783860667921,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}],"usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[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":1783860667921,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}} -{"type":"tool/result","seq":63,"time":1783860668018,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":64,"time":1783860668019,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":65,"time":1783860668019,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":66,"time":1783860669017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":67,"time":1783860669017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":68,"time":1783860669117,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":69,"time":1783860669145,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":70,"time":1783860669172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":71,"time":1783860669174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":72,"time":1783860669209,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":73,"time":1783860669210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":74,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"before"}}} -{"type":"assistant/chunk","seq":75,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":76,"time":1783860669236,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":77,"time":1783860669262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":78,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":79,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":80,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":81,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":82,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":83,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":84,"time":1783860669322,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":85,"time":1783860669323,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":86,"time":1783860669356,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":87,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":88,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":89,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":90,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":91,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."}}}} -{"type":"assistant/chunk","seq":92,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":93,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":94,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":95,"time":1783860669358,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":96,"time":1783860669358,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":97,"time":1783860669358,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":98,"time":1783860669383,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"permission/preset","seq":99,"time":1783860669383,"data":{"preset":"yolo"}} -{"type":"bash/sandbox-mode","seq":100,"time":1783860669383,"data":{"mode":"danger-full-access"}} -{"type":"approval/policy","seq":101,"time":1783860669383,"data":{"policy":"never"}} -{"type":"user/message","seq":102,"time":1783860669383,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":103,"time":1783860669384,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} -{"type":"step/start","seq":104,"time":1783860669384,"data":{"turn":2,"step":1}} -{"type":"request/header-delta","seq":105,"time":1783860669384,"data":{"system":{"keepStart":9,"keepEnd":0,"insert":["{{system}}","{{system}}"]}}} -{"type":"assistant/chunk","seq":106,"time":1783860670916,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":107,"time":1783860670916,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":108,"time":1783860671003,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":109,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":110,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":111,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":112,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":113,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":114,"time":1783860671079,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cat"}}} -{"type":"assistant/chunk","seq":115,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" out"}}} -{"type":"assistant/chunk","seq":116,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":117,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":118,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":119,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":120,"time":1783860671097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":121,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":122,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":123,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":124,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":125,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":126,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":127,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":128,"time":1783860671211,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":129,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":130,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":131,"time":1783860671228,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":132,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":133,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":134,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"Read"}}} -{"type":"assistant/chunk","seq":135,"time":1783860671261,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":136,"time":1783860671262,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":137,"time":1783860671301,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":139,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":140,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":141,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":142,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":143,"time":1783860671350,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":144,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"cat"}}} -{"type":"assistant/chunk","seq":145,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":146,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":147,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":148,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":149,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":150,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}}}} -{"type":"assistant/chunk","seq":151,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":152,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":153,"time":1783860671436,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}],"usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"tool/call","seq":154,"time":1783860671436,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}} -{"type":"tool/result","seq":155,"time":1783860671510,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[154],"surfaceOp":"append"} -{"type":"step/end","seq":156,"time":1783860671511,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":157,"time":1783860671511,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":158,"time":1783860673208,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":159,"time":1783860673208,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":160,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":161,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":162,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}}}} -{"type":"assistant/chunk","seq":163,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":164,"time":1783860673230,"data":{"turn":2,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}},"sourceEventSeqs":[158,159,160,161,162,163],"surfaceOp":"append"} -{"type":"step/end","seq":165,"time":1783860673230,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":166,"time":1783860673230,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":167,"time":1783860673237,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":168,"time":1783860673237,"data":{"content":[{"type":"text","text":"Without using any tools, state your current approval policy in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":169,"time":1783860673238,"data":{"turn":3,"step":1}} -{"type":"assistant/chunk","seq":170,"time":1783860674310,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":171,"time":1783860674310,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":172,"time":1783860674413,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":173,"time":1783860674433,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":174,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":175,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":176,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" state"}}} -{"type":"assistant/chunk","seq":177,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":178,"time":1783860674499,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} -{"type":"assistant/chunk","seq":179,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":180,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":181,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":182,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":183,"time":1783860674525,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} -{"type":"assistant/chunk","seq":184,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":185,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":186,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" From"}}} -{"type":"assistant/chunk","seq":187,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":188,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} -{"type":"assistant/chunk","seq":189,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}} -{"type":"assistant/chunk","seq":190,"time":1783860674581,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":191,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":192,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} -{"type":"assistant/chunk","seq":193,"time":1783860674610,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":194,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" changed"}}} -{"type":"assistant/chunk","seq":195,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} -{"type":"assistant/chunk","seq":196,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":197,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":198,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":199,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":200,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":201,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"never"}}} -{"type":"assistant/chunk","seq":202,"time":1783860674640,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":203,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":204,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"My"}}} -{"type":"assistant/chunk","seq":205,"time":1783860674700,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":206,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":207,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":208,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" set"}}} -{"type":"assistant/chunk","seq":209,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":210,"time":1783860674754,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" \""}}} -{"type":"assistant/chunk","seq":211,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"never"}}} -{"type":"assistant/chunk","seq":212,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":213,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":214,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" no"}}} -{"type":"assistant/chunk","seq":215,"time":1783860674786,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" actions"}}} -{"type":"assistant/chunk","seq":216,"time":1783860674817,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" requiring"}}} -{"type":"assistant/chunk","seq":217,"time":1783860674846,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} -{"type":"assistant/chunk","seq":218,"time":1783860674875,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} -{"type":"assistant/chunk","seq":219,"time":1783860674879,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" permitted"}}} -{"type":"assistant/chunk","seq":220,"time":1783860674880,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":221,"time":1783860674904,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":222,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" they"}}} -{"type":"assistant/chunk","seq":223,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} -{"type":"assistant/chunk","seq":224,"time":1783860674939,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":225,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" automatically"}}} -{"type":"assistant/chunk","seq":226,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":227,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."}}}} -{"type":"assistant/chunk","seq":228,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}}}} -{"type":"assistant/chunk","seq":229,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":230,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":231,"time":1783860674941,"data":{"turn":3,"step":1,"content":[{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."},{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}],"usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":232,"time":1783860674941,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":233,"time":1783860674941,"data":{"turn":3,"reason":{"kind":"completed"}}} +{"type":"permission/preset","seq":1,"time":1783962244578,"data":{"preset":"workspace-write"}} +{"type":"bash/sandbox-mode","seq":2,"time":1783962244578,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783860667446,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1783860667478,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":16,"time":1783860667479,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783860667501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":18,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":19,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":20,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":21,"time":1783860667502,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":22,"time":1783860667595,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":23,"time":1783860667596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":24,"time":1783860667623,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":25,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":27,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":28,"time":1783860667624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783860667656,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":30,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":32,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783860667657,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":34,"time":1783860667686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":35,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"before"}}} +{"type":"assistant/chunk","seq":36,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":37,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":38,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" >"}}} +{"type":"assistant/chunk","seq":39,"time":1783860667687,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":40,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":41,"time":1783860667710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" &&"}}} +{"type":"assistant/chunk","seq":42,"time":1783860667711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" cat"}}} +{"type":"assistant/chunk","seq":43,"time":1783860667738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":44,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":45,"time":1783860667739,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":47,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":49,"time":1783860667776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":51,"time":1783860667796,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783860667834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":53,"time":1783860667835,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":54,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" then"}}} +{"type":"assistant/chunk","seq":55,"time":1783860667863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" read"}}} +{"type":"assistant/chunk","seq":56,"time":1783860667889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":57,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":58,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":60,"time":1783860667918,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":61,"time":1783962244582,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1783962244582,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":63,"time":1783962244582,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1783962244582,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}],"usage":{"inputTokens":1411,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1783962244582,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","name":"bash","arguments":"{\"command\": \"printf 'before\\\\n' > out.txt && cat out.txt\", \"description\": \"Write and then read out.txt\"}"}} +{"type":"tool/result","seq":66,"time":1783962244599,"data":{"turn":1,"step":1,"callId":"call_00_E1vtulcKU1LKUgLahxdR3767","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1783962244599,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":68,"time":1783962244600,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":69,"time":1783860669145,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":70,"time":1783860669172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":71,"time":1783860669174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":72,"time":1783860669209,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":73,"time":1783860669210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":74,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":75,"time":1783860669235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":76,"time":1783860669236,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":77,"time":1783860669262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"before"}}} +{"type":"assistant/chunk","seq":78,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":79,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":80,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":81,"time":1783860669264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":82,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":83,"time":1783860669292,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":84,"time":1783860669322,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":85,"time":1783860669323,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":86,"time":1783860669356,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":87,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":88,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":89,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":90,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":91,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":92,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":93,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":94,"time":1783860669357,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."}}}} +{"type":"assistant/chunk","seq":95,"time":1783962244601,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":96,"time":1783962244601,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":97,"time":1783962244601,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":98,"time":1783962244601,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully, output \"before\". Now I need to reply with just the word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":109,"outputTokens":24,"cacheReadTokens":1408,"reasoningTokens":21}},"sourceEventSeqs":[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":1783962244601,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":100,"time":1783962244601,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":101,"time":1783962244623,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":102,"time":1783962244624,"data":{"preset":"danger-full-access"}} +{"type":"bash/sandbox-mode","seq":103,"time":1783962244624,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":104,"time":1783962244624,"data":{"policy":"never"}} +{"type":"user/message","seq":105,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"context/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} +{"type":"step/start","seq":107,"time":1783962244624,"data":{"turn":2,"step":1}} +{"type":"request/header-delta","seq":108,"time":1783962244624,"data":{"system":{"keepStart":9,"keepEnd":2,"insert":["{{system}}","{{system}}"]}}} +{"type":"assistant/chunk","seq":109,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":110,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":111,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":112,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":113,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":114,"time":1783860671079,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":115,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":116,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":117,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cat"}}} +{"type":"assistant/chunk","seq":118,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" out"}}} +{"type":"assistant/chunk","seq":119,"time":1783860671080,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":120,"time":1783860671097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":121,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":122,"time":1783860671101,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":123,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":124,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":125,"time":1783860671102,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":126,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":127,"time":1783860671175,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":128,"time":1783860671211,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":129,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":130,"time":1783860671212,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":131,"time":1783860671228,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":132,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":133,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":134,"time":1783860671229,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":135,"time":1783860671261,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":136,"time":1783860671262,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":137,"time":1783860671301,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"Read"}}} +{"type":"assistant/chunk","seq":138,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":139,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":140,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":141,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":142,"time":1783860671316,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":143,"time":1783860671350,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":144,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":145,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":146,"time":1783860671351,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"cat"}}} +{"type":"assistant/chunk","seq":148,"time":1783860671388,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":149,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":150,"time":1783860671435,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":151,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":152,"time":1783860671436,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":153,"time":1783962244626,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}}}} +{"type":"assistant/chunk","seq":154,"time":1783962244626,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":155,"time":1783962244626,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":156,"time":1783962244626,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `cat out.txt` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}],"usage":{"inputTokens":1608,"outputTokens":82,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":157,"time":1783962244626,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","name":"bash","arguments":"{\"description\": \"Read out.txt\", \"command\": \"cat out.txt\"}"}} +{"type":"tool/result","seq":158,"time":1783962244631,"data":{"turn":2,"step":1,"callId":"call_00_7Jb7FWHNjIBVML49dEJl1990","content":[{"type":"text","text":"before\n"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} +{"type":"step/end","seq":159,"time":1783962244631,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":160,"time":1783962244631,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":161,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":162,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":163,"time":1783860673229,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":164,"time":1783962244632,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":165,"time":1783962244632,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":166,"time":1783962244632,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":167,"time":1783962244632,"data":{"turn":2,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":40,"outputTokens":3,"cacheReadTokens":1664,"reasoningTokens":0}},"sourceEventSeqs":[161,162,163,164,165,166],"surfaceOp":"append"} +{"type":"step/end","seq":168,"time":1783962244632,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":169,"time":1783962244632,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":170,"time":1783962244637,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":171,"time":1783962244637,"data":{"content":[{"type":"text","text":"Without using any tools, state your current approval policy in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":172,"time":1783962244637,"data":{"turn":3,"step":1}} +{"type":"assistant/chunk","seq":173,"time":1783860674433,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":174,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":175,"time":1783860674435,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":176,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":177,"time":1783860674465,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":178,"time":1783860674499,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":179,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" state"}}} +{"type":"assistant/chunk","seq":180,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":181,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} +{"type":"assistant/chunk","seq":182,"time":1783860674500,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":183,"time":1783860674525,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":184,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":185,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":186,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":187,"time":1783860674526,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":188,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":189,"time":1783860674550,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" From"}}} +{"type":"assistant/chunk","seq":190,"time":1783860674581,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":191,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} +{"type":"assistant/chunk","seq":192,"time":1783860674582,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}} +{"type":"assistant/chunk","seq":193,"time":1783860674610,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":194,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":195,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":196,"time":1783860674611,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":197,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" changed"}}} +{"type":"assistant/chunk","seq":198,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":199,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":200,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":201,"time":1783860674638,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":202,"time":1783860674640,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":203,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":204,"time":1783860674698,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"never"}}} +{"type":"assistant/chunk","seq":205,"time":1783860674700,"data":{"turn":3,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":206,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":207,"time":1783860674726,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"My"}}} +{"type":"assistant/chunk","seq":208,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":209,"time":1783860674727,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":210,"time":1783860674754,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":211,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" set"}}} +{"type":"assistant/chunk","seq":212,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":213,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" \""}}} +{"type":"assistant/chunk","seq":214,"time":1783860674757,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"never"}}} +{"type":"assistant/chunk","seq":215,"time":1783860674786,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"\""}}} +{"type":"assistant/chunk","seq":216,"time":1783860674817,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" —"}}} +{"type":"assistant/chunk","seq":217,"time":1783860674846,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" no"}}} +{"type":"assistant/chunk","seq":218,"time":1783860674875,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" actions"}}} +{"type":"assistant/chunk","seq":219,"time":1783860674879,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" requiring"}}} +{"type":"assistant/chunk","seq":220,"time":1783860674880,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":221,"time":1783860674904,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} +{"type":"assistant/chunk","seq":222,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" permitted"}}} +{"type":"assistant/chunk","seq":223,"time":1783860674906,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":224,"time":1783860674939,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":225,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" they"}}} +{"type":"assistant/chunk","seq":226,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" are"}}} +{"type":"assistant/chunk","seq":227,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":228,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":" automatically"}}} +{"type":"assistant/chunk","seq":229,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":230,"time":1783860674940,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."}}}} +{"type":"assistant/chunk","seq":231,"time":1783962244639,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}}}} +{"type":"assistant/chunk","seq":232,"time":1783962244639,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":233,"time":1783962244639,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":234,"time":1783962244640,"data":{"turn":3,"step":1,"content":[{"type":"reasoning","text":"The user wants me to state the current approval policy without using any tools. From the context provided, the approval policy changed from \"ask\" to \"never\"."},{"type":"text","text":"My approval policy is set to \"never\" — no actions requiring approval are permitted, and they are rejected automatically."}],"usage":{"inputTokens":64,"outputTokens":56,"cacheReadTokens":1664,"reasoningTokens":32}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":235,"time":1783962244640,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":236,"time":1783962244640,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index 6db494fd93..65ab7ae4e7 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,5 +1,6 @@ {"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}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"request","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -42,8 +43,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_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"}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"yolo","options":[{"value":"request","name":"Request","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"yolo","name":"YOLO","description":"Full file access, no approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -67,7 +68,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7Jb7FWHNjIBVML49dEJl1990","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nbefore\n```"}}]}}} {"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":5,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":6,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -123,4 +124,4 @@ {"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":" automatically"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","id":6,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","id":7,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md index 2da5f39805..3da863d3b8 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md @@ -9,7 +9,9 @@ Check the [exit code: N] marker on every bash result; investigate failures befor - +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index a3ae2e3870..42d0507e3f 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 645671709e..1c85ee81ba 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index 00e707088e..7cc0dd72f2 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md index 18f0cbcd07..d0ce1272d0 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md @@ -1,16 +1,13 @@ You are an AI agent powered by the DeepSeek Harness SDK. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index e73195eeee..0c9ef3b472 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index e709fd09cd..b7e669238f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index 1a6b466e2b..519d784c79 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 8a42b58e7e..8cf5765647 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 324ef8d4eb..c66f5676cc 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index dda3afb9c5..40c8434937 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md index 18f0cbcd07..d0ce1272d0 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md @@ -1,16 +1,13 @@ You are an AI agent powered by the DeepSeek Harness SDK. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - Check the [exit code: N] marker on every bash result; investigate failures before moving on. +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index fb9635366b..b99e45c699 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 3d0352468c..1ed804a0d6 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 63af2375d5..b188480ad5 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 9a908f24a3..04e3c3b59e 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -79,7 +79,7 @@ {"type":"assistant/chunk","seq":77,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":78,"time":1783352265491,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"surfaceOp":"append"} {"type":"tool/call","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"/tmp/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","seq":81,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":82,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":83,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 7b1ded9550..d3fd50b416 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"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":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace; anything wider asks for your approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access, no approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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"}}}} @@ -55,8 +55,8 @@ {"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_OjRFB4zvxu6UALDjytZD0978","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_OjRFB4zvxu6UALDjytZD0978","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_OjRFB4zvxu6UALDjytZD0978","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"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":" contains"}}}} diff --git a/package.json b/package.json index 2a4b635d26..dcf7fecc75 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,7 @@ "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/cordis-agent/cordis.yml", - "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", - "demo:sandbox-acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/sandbox.cordis.yml", + "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts --config examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/bash/README.md b/packages/bash/README.md index 9b1bb79cad..c850ce2c15 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -9,4 +9,4 @@ The canonical three-package capability seam (see [capability seams](../../docs/r | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's `sandbox.cordis.yml`](../../examples/acp-agent/)). +The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)). diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index c6b04e2206..c3407b5d1e 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -30,4 +30,4 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego workspaceRoot: !!js process.cwd() ``` -The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's sandbox variant](../../../examples/acp-agent/) for the runnable demo. +The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index cab1bcebb2..cb2c7b5747 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -9,4 +9,4 @@ The confinement half of the [capability-seam split](../../docs/rfc/implemented/a The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's sandbox variant](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase). +Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase). diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index edcd55c6e7..87f65048e4 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -15,4 +15,4 @@ Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2 name: '@deepseek-ai/dsh-sandbox-local' ``` -Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example's sandbox variant (`sandbox.cordis.yml`)](../../../examples/acp-agent/) for the runnable composition. +Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example](../../../examples/acp-agent/) for the runnable default composition. diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index c949685fcc..329f34a91a 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th config: providerName: acp command: node - args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml'] + args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml'] permission: reject env: DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 7814dfaca3..75e4febc3e 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -48,7 +48,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, binScript, exampleConfig], + args: ['--import', tsxLoader, binScript, '--config', exampleConfig], cwd: workdir, permission: 'reject', // The child harness needs the key to reach the model; forward it @@ -57,6 +57,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, TSX_TSCONFIG_PATH: repoTsconfig, + DSH_PERMISSION_MODE: 'danger-full-access', }, }) @@ -83,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, binScript, exampleConfig], + args: ['--import', tsxLoader, binScript, '--config', exampleConfig], cwd: workdir, // The child needs to act (run bash), so approve its permission prompts. permission: 'allow', @@ -91,6 +92,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, TSX_TSCONFIG_PATH: repoTsconfig, + DSH_PERMISSION_MODE: 'danger-full-access', }, }) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 491d3ea884..f2970d1340 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -222,7 +222,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child = spawn( process.execPath, - ['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath], + ['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, ) diff --git a/packages/ui/README.md b/packages/ui/README.md index 088dcc65e2..fdd0bc694b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -6,7 +6,7 @@ Integrations that expose the agent to an external editor or client. These are ** |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | -| `permission/` | User-facing permission presets (request/yolo): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | +| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `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`) | diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index d81c9cc091..a9622f1eeb 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -29,11 +29,11 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). +The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. ## The bin -`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`): +`dsh-acp-agent [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`): - loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call; - honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 1cbdff64d1..ddad5f0d21 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -22,11 +22,13 @@ * 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`). + * Usage: `dsh-acp-agent [--config path-to-cordis.yml]` (default + * `./cordis.yml`). * * @module @deepseek-ai/dsh-acp-agent/bin */ +import { parseArgs } from 'node:util' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' const NAME = 'dsh-acp-agent' @@ -37,7 +39,12 @@ const NAME = 'dsh-acp-agent' 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)) +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { config: { type: 'string', short: 'c' } }, + strict: true, +}) +const ctx = await boot(NAME, resolveConfigPath(values.config ?? './cordis.yml', snapshotMode)) if (snapshotMode !== undefined) { process.stdin.on('end', () => { void ctx.fiber.dispose().then(() => { process.exit(0) }) diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index de4612a40a..ac6965ff65 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -117,7 +117,7 @@ afterEach(async () => { describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => { it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => { consumer = await makeConsumer() - child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], { + child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], { cwd: consumer, // Dummy key: initialize never reaches the model, so it is never used. env: { @@ -183,7 +183,7 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, /** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { return new Promise((resolve, reject) => { - const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], { + const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], { cwd, env: { ...process.env, diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/ui/acp-agent/tests/load-path.e2e.ts index ec251a6e6b..963b440373 100644 --- a/packages/ui/acp-agent/tests/load-path.e2e.ts +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -82,7 +82,7 @@ async function boot(): Promise { await writeFile(configPath, CORDIS_YML) const child = spawn( process.execPath, - ['--import', tsxLoader, binScript, configPath], + ['--import', tsxLoader, binScript, '--config', configPath], { cwd, env: { diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index f4947e5d25..03e98f9cff 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -32,7 +32,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `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` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" | -| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | +| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | ## Multi-session @@ -40,7 +40,7 @@ The bridge multiplexes N sessions over one connection. Live sessions are held in ## Session config options -The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6. +When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6. 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. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index be14fe6237..36ed963b14 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session sandbox/approval config options. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | | `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | | `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | -| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). | -| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). | +| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). | +| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). | | model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. | | `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. | | `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. | @@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult ## 6. Session modes / config options / models -Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector). +Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next turn under the turn-enclosure contract. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector). ## 7. Content blocks diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a027ec409d..25f2ea20f2 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -855,7 +855,7 @@ export function apply(ctx: Context, config: AcpConfig): void { setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { assertOpen() const rec = requireSession(SessionId(params.sessionId)) - // Both advertised options are selects, so the boolean-shaped variant of + // The advertised option is a select, so the boolean-shaped variant of // the request is a protocol misuse regardless of configId. if (typeof params.value !== 'string') { throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`) diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 254a649b5d..fb39b2a0de 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -24,7 +24,7 @@ import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.t * the documented capability override point (`dsh-bash-sandbox` overrides it * the same way), so the bridge sees exactly what a sandboxing composition * advertises without this suite dragging in a kernel sandbox stack. It - * reports `workspace-write`: the shipped `request` preset's bundle, which + * reports `workspace-write`: the shipped preset's bundle, which * the permission service validates the composition defaults against. */ class SandboxedLocalExecutor extends LocalBashExecutor { @@ -43,8 +43,8 @@ function permissionOption(currentValue: string): object { type: 'select', currentValue, options: [ - { value: 'request', name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.' }, - { value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' }, + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' }, ], } } @@ -87,15 +87,15 @@ describe('acp bridge — session config options', () => { it('advertises the Permissions select with the default preset current', async () => { h = await presetStack() const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([permissionOption('request')]) + expect(res.configOptions).toEqual([permissionOption('workspace-write')]) }) it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - expect(after.configOptions).toEqual([permissionOption('yolo')]) + const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + expect(after.configOptions).toEqual([permissionOption('danger-full-access')]) // Idle: nothing in the log yet — turn-enclosure forbids a bare append. const session = h.ctx.agents.list()[0]?.session @@ -103,7 +103,7 @@ describe('acp bridge — session config options', () => { await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = session?.events ?? [] - expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }]) + expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }]) expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) const turnStart = events.findIndex(e => e.type === 'turn/start') @@ -115,24 +115,24 @@ describe('acp bridge — session config options', () => { it('an idle flip-flop anchors as ONE switch (last write wins)', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - expect(again.configOptions).toEqual([permissionOption('yolo')]) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + expect(again.configOptions).toEqual([permissionOption('danger-full-access')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1) // Between turns (a closed turn in the log) a switch still pends — the // enclosure fold walks past the turn/end — and anchors with the NEXT turn. - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' }) expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1) }) it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) - expect(back.configOptions).toEqual([permissionOption('request')]) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' }) + expect(back.configOptions).toEqual([permissionOption('workspace-write')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) @@ -141,14 +141,14 @@ describe('acp bridge — session config options', () => { it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'request' }) - expect(echo.configOptions).toEqual([permissionOption('request')]) - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) - expect(repeat.configOptions).toEqual([permissionOption('yolo')]) + const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' }) + expect(echo.configOptions).toEqual([permissionOption('workspace-write')]) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) + expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'yolo' }]) + expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }]) }) it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => { @@ -157,7 +157,7 @@ describe('acp bridge — session config options', () => { const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) // Give the loop a tick to open the turn (the turns.spec hang idiom). await new Promise(resolve => setTimeout(resolve, 30)) - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) const events = h.ctx.agents.list()[0]?.session.events ?? [] const turnStart = events.findIndex(e => e.type === 'turn/start') const anchored = events.findIndex(e => e.type === 'permission/preset') @@ -178,7 +178,7 @@ describe('acp bridge — session config options', () => { await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' })) .rejects.toThrow(/unknown config option/) // `permission` exists as a concept but THIS composition never advertised it. - await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' })) + await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })) .rejects.toThrow(/unknown permission value/) await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true })) .rejects.toThrow(/select; boolean values are not accepted/) @@ -195,13 +195,13 @@ describe('acp bridge — session config options', () => { h = await presetStack() const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' }) + await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' }) // B sees the composition default, not A's pending switch... - const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'request' }) - expect(bAfter.configOptions).toEqual([permissionOption('request')]) + const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' }) + expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')]) // ...and A keeps its own state, untouched by B's. - const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'yolo' }) - expect(aAfter.configOptions).toEqual([permissionOption('yolo')]) + const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' }) + expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')]) }) it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => { @@ -220,14 +220,14 @@ describe('acp bridge — session config options', () => { const option = echo.configOptions?.[0] expect(option).toMatchObject({ currentValue: 'custom' }) if (option === undefined || !('options' in option)) throw new Error('expected a select option') - expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo', 'custom']) + expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom']) // …while custom as a TARGET from a real preset stays rejected: switching // away is ordinary, and the custom entry disappears from the options. - const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) const afterOption = away.configOptions?.[0] - expect(afterOption).toMatchObject({ currentValue: 'yolo' }) + expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' }) if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option') - expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['request', 'yolo']) + expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access']) await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })) .rejects.toThrow(/unknown permission value/) }) @@ -235,7 +235,7 @@ describe('acp bridge — session config options', () => { it('session/load reports a resumed session\'s preset from its own log', async () => { h = await presetStack({ script: [textResponse('ok')] }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'yolo' }) + await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }) // One turn checkpoints the log (the switch events flush with it). await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] }) await h.dispose() @@ -243,6 +243,6 @@ describe('acp bridge — session config options', () => { loader = await presetStack() const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) - expect(res.configOptions).toEqual([permissionOption('yolo')]) + expect(res.configOptions).toEqual([permissionOption('danger-full-access')]) }) }) diff --git a/packages/ui/jsonrpc-agent/README.md b/packages/ui/jsonrpc-agent/README.md index d1836c737d..01c8723893 100644 --- a/packages/ui/jsonrpc-agent/README.md +++ b/packages/ui/jsonrpc-agent/README.md @@ -4,7 +4,7 @@ The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from a ## Config discovery -Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent `, the human channel, isomorphic to `dsh-acp-agent`). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier. +Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent `, the human channel). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier. Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server". diff --git a/packages/ui/jsonrpc-agent/src/bin.ts b/packages/ui/jsonrpc-agent/src/bin.ts index b2c9f0c3d2..a70251a2be 100644 --- a/packages/ui/jsonrpc-agent/src/bin.ts +++ b/packages/ui/jsonrpc-agent/src/bin.ts @@ -9,7 +9,7 @@ * * - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client * convention, wins) or the `argv[2]` positional path (the human channel, - * isomorphic to `dsh-acp-agent`); an empty value counts as absent. Neither + * for direct launches); an empty value counts as absent. Neither * given, or the path missing on disk, prints the one-line usage to stderr * and exits 1. No built-in fallback — the external config IS the deployment * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 57526c9ad2..2f6b12054b 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -1,7 +1,7 @@ # @deepseek-ai/dsh-permission -User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `request` (`workspace-write` + `ask`) and `yolo` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary. +User-facing permission presets. Owns the `ctx.permission` service ([`PermissionService`](src/index.ts)): a config-defined preset table — by default `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`) — where each name bundles the two mechanism knobs, `bash/sandbox-mode` and `approval/policy`. The product surface (the ACP bridge's single `Permissions` select) advertises `names` and calls `set()`; the mechanism tiers stay orthogonal capabilities that never learn the product vocabulary. A switch WRITES THROUGH: `set(session, name)` appends one log-only `permission/preset` event when the name differs from the session's current preset (the audit fact reverse-mapping cannot recover — two presets may share knob values and differ only in composed policy, the planned `agent` preset being the standing example), then each knob event through its own THE-write-path setter, skipping values the session already effectively has — a net-zero switch appends nothing. The current preset DERIVES from the effective knob values (fold ?? composition default per knob): the last-chosen preset when its bundle still matches (presets may share bundles — the fold breaks the tie), else the first matching table entry, else the reserved `custom` — the honest not-a-preset state, shown as the current value only while it holds, switchable FROM and never a target. Every existing knob consumer (executor stamping, the approval gate, narrators, resume) keeps reading its own fold, untouched. -Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's sandbox variant](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over. +Composing it requires a confining `ctx.bash` executor and the `ctx.approval` seam; a table entry named `custom` throws at load (the name is reserved), while composition defaults outside the table are not an error — a zero-event session simply derives `custom`. See [the acp-agent example's default tree](../../../examples/acp-agent/) for the composed leaf and [the sandbox RFC § Per-session modes](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design this layers over. diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 84c9fef4a6..67e623052b 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -2,8 +2,8 @@ * User-facing PERMISSION PRESETS: one product-level knob over the two * mechanism knobs. A preset names a bundle — its sandbox mode * (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a - * user picks `request` or `yolo` where the mechanism tiers stay orthogonal - * capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event + * user picks `workspace-write` or `danger-full-access` while the mechanism + * tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event * records the chosen bundle (the audit fact reverse-mapping cannot recover — * two presets may share knob values and differ only in composed policy, the * planned `agent` preset being the standing example), then each knob event @@ -96,9 +96,9 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri /** The {@link PermissionService} config: the deployment's preset table. */ export interface Config { /** - * The preset table: name → knob bundle. Defaults to `request` - * (workspace-write + ask) and `yolo` (danger-full-access + never). The - * name `custom` is reserved for the derived not-a-preset state. + * The preset table: name → knob bundle. Defaults to `workspace-write` + * (workspace-write + ask) and `danger-full-access` (danger-full-access + + * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record } @@ -121,13 +121,14 @@ export class PermissionService extends Service { name: z.string(), description: z.string(), })).default({ - request: { + // Keep the user-facing preset names explicit about filesystem reach. + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', - name: 'Request', description: 'Write inside the workspace; anything wider asks for your approval.', + name: 'workspace-write', description: 'Write inside the workspace; anything wider asks for your approval.', }, - yolo: { + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', - name: 'YOLO', description: 'Full file access, no approval prompts.', + name: 'danger-full-access', description: 'Full file access, no approval prompts.', }, }), }) diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 8bf0bb075f..2852b2d96b 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -28,26 +28,26 @@ describe('effectivePermissionPreset', () => { it('folds to the last event, or undefined without one', () => { const session = freshSession('sess-fold') expect(effectivePermissionPreset(session.events)).toBeUndefined() - session.append('permission/preset', { preset: 'yolo' }) - session.append('permission/preset', { preset: 'request' }) - expect(effectivePermissionPreset(session.events)).toBe('request') + session.append('permission/preset', { preset: 'danger-full-access' }) + session.append('permission/preset', { preset: 'workspace-write' }) + expect(effectivePermissionPreset(session.events)).toBe('workspace-write') }) }) describe('PermissionService', () => { it('advertises the preset table in declaration order and resolves bundles', async () => { const ctx = await mounted() - expect(ctx.permission.names).toEqual(['request', 'yolo']) - expect(ctx.permission.resolve('yolo')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' }) + expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access']) + expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' }) expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/) }) - it('current() derives from the effective knobs: composition defaults hit request, a switch hits its preset', async () => { + it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => { const ctx = await mounted() const session = freshSession('sess-current') - expect(ctx.permission.current(session.events)).toBe('request') - ctx.permission.set(session, 'yolo') - expect(ctx.permission.current(session.events)).toBe('yolo') + expect(ctx.permission.current(session.events)).toBe('workspace-write') + ctx.permission.set(session, 'danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) it('a knob state matching no table entry derives custom — a state, not an error', async () => { @@ -57,8 +57,8 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) // Switching FROM custom is an ordinary write-through; custom itself is // never a target. - ctx.permission.set(session, 'yolo') - expect(ctx.permission.current(session.events)).toBe('yolo') + ctx.permission.set(session, 'danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) @@ -70,26 +70,26 @@ describe('PermissionService', () => { it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { const ctx = await mounted({ config: { presets: { - request: { sandbox: 'workspace-write', approval: 'ask' }, + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }, agentish: { sandbox: 'workspace-write', approval: 'ask' }, - yolo: { sandbox: 'danger-full-access', approval: 'never' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' }, } } }) const session = freshSession('sess-tie') - // Same bundle as request, chosen explicitly: the fold names it. + // Same bundle as workspace-write, chosen explicitly: the fold names it. ctx.permission.set(session, 'agentish') expect(ctx.permission.current(session.events)).toBe('agentish') // A knob drifts: the fold's bundle no longer matches → reverse map wins. session.append('approval/policy', { policy: 'never' }) session.append('bash/sandbox-mode', { mode: 'danger-full-access' }) - expect(ctx.permission.current(session.events)).toBe('yolo') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { const ctx = await mounted() const session = freshSession('sess-set') - ctx.permission.set(session, 'yolo') + ctx.permission.set(session, 'danger-full-access') expect(session.events.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'yolo' }], + ['permission/preset', { preset: 'danger-full-access' }], ['bash/sandbox-mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) @@ -98,22 +98,22 @@ describe('PermissionService', () => { it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => { const ctx = await mounted() const session = freshSession('sess-noop') - ctx.permission.set(session, 'request') + ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) }) it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => { const ctx = await mounted() const session = freshSession('sess-drift') - ctx.permission.set(session, 'yolo') + ctx.permission.set(session, 'danger-full-access') // A knob drifts out from under the preset (a direct setter call, a test // scenario): the session derives custom, and re-asserting the preset is // a real switch again — choice re-recorded, only the drifted knob moves. session.append('bash/sandbox-mode', { mode: 'read-only' }) - ctx.permission.set(session, 'yolo') + ctx.permission.set(session, 'danger-full-access') const tail = session.events.slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'yolo' }], + ['permission/preset', { preset: 'danger-full-access' }], ['bash/sandbox-mode', { mode: 'danger-full-access' }], ]) }) @@ -125,7 +125,7 @@ describe('PermissionService', () => { it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => { const ctx = await mounted() - expect(ctx.permission.optionOf('yolo')).toEqual({ value: 'yolo', name: 'YOLO', description: 'Full file access, no approval prompts.' }) + expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access, no approval prompts.' }) expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'A hand-set knob combination outside the preset table.' }) const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } }) expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' }) @@ -140,8 +140,8 @@ describe('PermissionService', () => { it('reads a schema-less approval stand-in as the ask default', async () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') - ctx.permission.set(session, 'request') + ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permission.current(session.events)).toBe('request') + expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7d59187de..f2d64e8671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1734,6 +1734,9 @@ importers: '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../packages/ui/permission '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 76e6bf157f..a843b2f04a 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index a93ea98173..d8c975190c 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -15,7 +15,7 @@ import { spawn } from 'node:child_process' // Loader's HMR path). const UIS = new Map([ ['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']], - ['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) const ui = process.argv[2] ?? 'repl' diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c268023e39..998d24040c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -195,7 +195,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', implementations: [], consumers: ['acp'], - note: 'User-facing preset table (request/yolo) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.', + note: 'User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.', }, { key: 'codeRuntime', From 7756ed086937e81e5fb9b9465b5f20c1b8b3be0e Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:34:30 -0700 Subject: [PATCH 30/31] docs(i18n): re-record README pair after master merge --- docs/i18n/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 2c81577523..6764f01a21 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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: 81e958c8ae9552222f27b0f9674d4fa0ff2a481c -README.zh.md: 3764a9ade69f3ad99e36ccccbaed1d19d8bbb1f0 +README.md: 30b9e53383ef591898505375662ec94d732de304 +README.zh.md: eac836e7c310945ec345db9974be9f1deaae4c60 From 1d288f1bca81fcda2b2445db585af48a9424a973 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:37:21 -0700 Subject: [PATCH 31/31] =?UTF-8?q?docs(i18n):=20address=20ds-review-bot=20r?= =?UTF-8?q?ound-2=20=E2=80=94=20switcher=20placeholders,=20regex=20anchor,?= =?UTF-8?q?=20contract=20completeness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 {{source_filename}}/{{source_filename_zh}} 占位符:切换行指令 可执行化,新配对(源文件尚无切换行)也必须生成(critical)。加粗 后空格规则限定于拉丁字母/数字,不再与全角标点规则冲突。日期匹配 锚定 basename 开头,release-notes-2026-* 类文件不再误判。金标 development.zh 补回 RFC 指引句与 agent 首现括注、pre-push 描述改 为 check:pre-push 调度器实况。术语表补回单文件可执行 RFC 配对在用 的 5 行(VFS/deploy root/peer dependency/serving surface/wheel)。 双语 README 的门禁清单补第 4 条(日期分界强制配对)。 --- docs/development.i18n.yaml | 2 +- docs/development.zh.md | 6 +++--- docs/i18n/README.i18n.yaml | 2 +- docs/i18n/README.md | 1 + docs/i18n/terminology.md | 5 +++++ docs/i18n/translation-prompt.md | 6 ++++-- scripts/verify-translation-pairing.ts | 2 +- 7 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 1702dcf2a9..1959a44b4d 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write development.md: ea5f2e5d08acbaf1dfce4661530218dbf1a051b9 -development.zh.md: d7263d63ce2d3b851d38e633641c0324c9fb0460 +development.zh.md: 9c0cc23373b0f4feba8538fcbd25b38a483e0ba0 diff --git a/docs/development.zh.md b/docs/development.zh.md index d7263d63ce..9c0cc23373 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,14 +2,14 @@ [English](development.md) | 中文 -本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你了解本地钩子、日常检查与 CI 门禁。 +本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 RFC。 ## 前置条件 - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git。 -- 可选:一个 DeepSeek API key,用于 REPL/ACP agent 演示和真实 API 的 e2e 测试。 +- 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。 ## 首次搭建 @@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional 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`。 +- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行单元测试、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene` 与 `pnpm run doc-sync` 的各成员门禁。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 6764f01a21..d9312030c9 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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: 30b9e53383ef591898505375662ec94d732de304 +README.md: d600773af2be156f52746c5bbd1853ff2ab46c14 README.zh.md: eac836e7c310945ec345db9974be9f1deaae4c60 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 30b9e53383..d600773af2 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -26,6 +26,7 @@ This repo's documentation is read by people and agents both inside and outside t 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. +4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new documents merge bilingual from birth. `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. diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f5dcb1f779..69f0e170e0 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -63,6 +63,7 @@ | thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | | waterfall | waterfall | waterfall(瀑布式事件) | | | +| wheel | wheel 包 | | | Python 打包格式 | | worktree | worktree | | | git 工作区概念 | ## 双语类(中英文文本各自使用中英文) @@ -92,6 +93,7 @@ | contract | 契约 | | | 如:`pairing contract` →`配对契约` | | coverage | 覆盖率 | | | | | crash recovery | 崩溃恢复 | | | | +| deploy root | 部署根目录 | | | | | durability | 持久性 | | | | | enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 | | event | 事件 | | | | @@ -124,6 +126,7 @@ | orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | | pairing | 配对 | | | | +| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | | | permission | 权限 | | | | | persistence | 持久化 | | | | | pipeline | 流水线 | | | | @@ -140,6 +143,7 @@ | runtime | 运行时 | | | | | sandbox | 沙箱 | | | | | service | 服务 | | | | +| serving surface | 对外服务接口 | | | | | session | 会话 | | | | | session event | 会话事件 | | | | | sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 | @@ -162,6 +166,7 @@ | tool schema | 工具 schema | | | | | toolkit | 工具包 | | | | | turn | 轮次 | | | | +| VFS | VFS | 虚拟文件系统(VFS) | | | | typecheck | 类型检查 | | | | | vocabulary | 词汇 | | | | | wire format | 协议格式 | 协议格式(wire format) | | | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 211d36a175..9978e2d29b 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -11,6 +11,8 @@ | `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` | | `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 | | `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | +| `{{source_filename}}` | 源文档的 basename(如 `foo.md`) | 由流水线从待译文件路径取得 | +| `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 由 `{{source_filename}}` 派生 | 历史模板的 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 占位符与 `%%` 分段协议已废弃:本模板按整文档翻译(非分段),输出协议为下方三段 XML。 @@ -40,8 +42,8 @@ You are a senior technical translator specializing in LLM and agent development - Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions. - Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. - Every relative link must point to the same target as in the source. Link text is translated; link targets are not. -- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. -- After a closing bold marker `**`, always insert a space before the next character. +- Language switcher line: the source document's filename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1 heading. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1. Emit this line even when the source file has no switcher yet (a brand-new pair); when the source does have one, flip the link direction — never copy it unchanged. +- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter or digit. Never insert a space before full-width (Chinese) punctuation. ### Tone and Style - The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index c0c56ffe46..8297d13fe8 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -257,7 +257,7 @@ for (const req of manifest.required) { // 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge // bilingual: a new RFC lands with its pair or not at all. Deterministic from // the filename alone — no git history, so it holds on shallow CI checkouts. -const DATED = /(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ +const DATED = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/ for (const source of sources) { if (isExcluded(source)) continue const dated = DATED.exec(source)