From 669771097db2aa4fbc4910e505c56351ccfceac4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 04:06:19 +0800 Subject: [PATCH 01/61] subagent: inherit parent sandbox/approval overrides in in-process children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-session policy overrides (sandbox/mode, approval/policy) never crossed the delegation boundary: a spawn child of a read-only-switched parent ran under the wider deployment default, and a fork child missed any switch made after its seed boundary — delegation was a bypass channel for a user's tightening. The in-process driver now snapshots the delegating parent's override chain and stamps it onto the child through the canonical write paths (SandboxPolicyService.inheritOverride / ApprovalService.inheritOverride), anchored inside the child's first turn via a one-shot agent/prompt-submit listener: turn-enclosed (durable), ahead of the first request (an inherited 'never' reaches the child's first system prompt), and positioned after any stale fork-seed switch so the ordinary last-event-wins fold resolves it. Only overrides are copied — an unswitched parent stamps nothing and the child follows the live deployment default; both services are consumed opportunistically, so compositions without them delegate unchanged. Nesting composes by construction (each stamp folds the already-stamped parent log). Evidence: inheritance.spec.ts drives scripted-model children into the real dsh-fs-sandbox fence through the real write tool (disk-state + denial-marker assertions; spawn, stale-seed fork, grandchild, escalation fail-closed, and no-stamp guards), inheritOverride contract tests in both service suites, and the recorded subagent-sandbox-inheritance ACP snapshot (read-only preset → delegate → child denied, replayed keylessly). See .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md. --- .../feature/2026-07-06-approval-seam.md | 2 +- .../implemented/feature/2026-07-06-sandbox.md | 2 +- ...7-25-subagent-policy-inheritance.i18n.yaml | 6 + .../2026-07-25-subagent-policy-inheritance.md | 37 ++ ...26-07-25-subagent-policy-inheritance.zh.md | 37 ++ docs/cordis-catalog/services.md | 32 +- docs/event-producer-consumer.md | 2 +- .../subagent-inheritance.cordis.snapshot.yml | 65 +++ .../acp-agent/subagent-inheritance.cordis.yml | 44 ++ examples/acp-agent/tests/acp.snapshot.ts | 14 + .../subagent-sandbox-inheritance/input.json | 19 + .../session.1.jsonl | 238 ++++++++ .../session.jsonl | 323 +++++++++++ .../stdout.expected.jsonl | 200 +++++++ .../system-prompt.expected.md | 24 + .../tool-schemas.expected.json | 546 ++++++++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/sandbox/sandbox-policy/README.md | 1 + packages/sandbox/sandbox-policy/src/index.ts | 20 +- .../sandbox-policy/tests/policy.spec.ts | 44 ++ .../subagent/subagent-inprocess/README.md | 2 + .../subagent/subagent-inprocess/package.json | 6 + .../subagent/subagent-inprocess/src/index.ts | 19 + .../tests/inheritance.spec.ts | 403 +++++++++++++ .../subagent/subagent-inprocess/tsconfig.json | 6 + packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 18 + .../ui/user-approval/tests/approval.spec.ts | 43 ++ pnpm-lock.yaml | 12 + 29 files changed, 2168 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md create mode 100644 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md create mode 100644 examples/acp-agent/subagent-inheritance.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-inheritance.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json create mode 100644 packages/subagent/subagent-inprocess/tests/inheritance.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index af063895e2..2fa8252682 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -121,7 +121,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. The session POLICY does cross the boundary: a `'never'` parent's children inherit `'never'` via a stamped override ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)), so they are told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index d5d0c6d5aa..a8a11f5956 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -90,7 +90,7 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's `workspace-write` cannot disturb another's `read-only`) both fall out by construction, and no external config store exists anywhere. +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's `workspace-write` cannot disturb another's `read-only`) both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver stamps a delegating parent's effective override onto each child session inside the child's first turn, so a tightened parent binds spawn children, fork children, and grandchildren ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml new file mode 100644 index 0000000000..ab8cf722de --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-25-subagent-policy-inheritance.md: bc5eb5b17ce34872db50b4cf848f6a1784ede0fc +2026-07-25-subagent-policy-inheritance.zh.md: 3498b1b01987c44156b922fe0dcdd6223495fed6 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md new file mode 100644 index 0000000000..bc5eb5b17c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -0,0 +1,37 @@ +# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides + +Status: implemented + +English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) + +## Problem + +Session policy overrides are per-session log folds: the effective sandbox mode is `fold(session's sandbox/mode events) ?? deployment default` ([the sandbox Agent Note](2026-07-06-sandbox.md)), and the approval policy folds `approval/policy` the same way. In-process subagent children get a NEW session, so no override crossed the delegation boundary: a spawn child of a `read-only`-switched parent ran under the (possibly wider) deployment default — delegation was a bypass channel for a user's tightening — and a fork child inherited only whatever switch happened to sit inside its completed-turn seed, missing exactly the most common timing (the user switches while the agent is idle, so the switch lands after the last `turn/end` and outside the seed). A `'never'` (headless/CI) approval parent likewise minted children that fell back to a prompting default. The escalation hint a denied child sees ("the approval prompt asks the user") also promised a prompt no answerer would ever deliver. + +## Decision + +The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) snapshots the parent's policy overrides at delegation and stamps them onto the child as ordinary log events inside the child's FIRST turn: + +- **Read at creation, write at first `agent/prompt-submit`.** The driver installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. +- **Only the override chain is copied, through the canonical write paths.** `SandboxPolicyService.inheritOverride(parent, child)` and `ApprovalService.inheritOverride(parent, child)` each fold the parent's FULL live log (not the fork seed), append via `setSandboxMode`/`setApprovalPolicy` only when the parent has an override the child does not already fold to, and never copy the deployment default — an unswitched parent stamps nothing, so a resumed child keeps following the LIVE default. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. +- **Fork stale-seed precedence falls out of log order.** The stamped event lands after any switch the seed carried, so the existing last-event-wins fold resolves the child's mode with no new precedence machinery; an equal seed-carried override is deduplicated instead of re-stamped. +- **Nesting composes by construction.** A grandchild's stamp folds its parent-the-child's log, which already contains the child's stamped (or self-switched) override — the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter any log, so they can never leak down the chain. + +### What a blocked child experiences + +A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by an editor — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation. + +## Alternatives considered + +- **A `sandboxMode`/`approvalPolicy` baseline in `SessionHeader` meta (the `delegationDepth` precedent)** — rejected: it survives the one corner the event approach loses (a child hard-killed before its first `turn/end` and then resumed loses the stamp), but that child has completed nothing and has no resume value, while the header field costs a session-format extension, a durable-boundary validation path, seed-slicing precedence logic in every fold consumer (`resolve()`, pty-local, permission display), and a second home for policy state. The event approach changes no fold, no format, and no consumer. +- **Stamping at child creation (outside any turn)** — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright. +- **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. +- **Forcing `approvalPolicy: 'never'` onto every in-process child** — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons. +- **Routing a child's approval asks to the root session's editor** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived. + +## Consequences + +- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers) and the `inheritOverride` contract tests in the two service suites. +- The stamped override is the child's own durable record: resume replays it like any switch, and the child may later be switched independently without the driver re-stamping over it (one-shot listener + fold dedup). +- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); a child hard-killed before its first `turn/end` loses the stamp on resume (worthless-resume corner, recorded above); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. +- `dsh-subagent-inprocess` now declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md new file mode 100644 index 0000000000..3498b1b019 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动 + +Status: implemented + +[English](2026-07-25-subagent-policy-inheritance.md) | 中文 + +## 问题 + +会话策略覆盖项是按会话的日志折叠:生效沙箱模式等于 `fold(session's sandbox/mode events) ?? deployment default`([沙箱 Agent Note](2026-07-06-sandbox.md)),审批策略以同样的方式折叠 `approval/policy`。进程内 subagent 的子 agent(智能体)拿到的是一个全新会话,因此没有任何覆盖项能跨过委派边界:父 agent 已切换到 `read-only` 时,其 spawn 子 agent 却运行在(可能更宽的)部署默认值之下,委派成了绕开用户收紧的旁路通道;fork 子 agent 只能继承恰好落在其已完成轮次种子内的切换,而恰恰漏掉最常见的时机(用户在 agent 空闲时切换,切换落在最后一个 `turn/end` 之后、种子之外)。审批策略为 `'never'`(无头/CI 场景)的父 agent,其创建出的子 agent 同样回退到了会向用户弹出提示的默认策略。被拒的子 agent 看到的升级提示文案(「审批提示会询问用户」)还承诺了一个永远不会有应答器送达的提示。 + +## 决策 + +共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: + +- **创建时读取,首个 `agent/prompt-submit` 时写入。**驱动器在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 +- **只复制覆盖链,且全部走规范写入路径。**`SandboxPolicyService.inheritOverride(parent, child)` 与 `ApprovalService.inheritOverride(parent, child)` 各自折叠父级的完整实时日志(而非 fork 种子),只在父级持有子 agent 尚未折叠出的覆盖项时才通过 `setSandboxMode`/`setApprovalPolicy` 追加,并且从不复制部署默认值:未切换过的父级不盖任何章,因此恢复后的子 agent 继续跟随实时默认值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 +- **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 + +### 被拦住的子 agent 会经历什么 + +受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由编辑器持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。 + +## 考虑过的替代方案 + +- **在 `SessionHeader` 的 meta 中放 `sandboxMode`/`approvalPolicy` 基线(沿用 `delegationDepth` 先例)**:不予采纳。它确实能扛住事件方案唯一丢失的边角场景(子 agent 在第一个 `turn/end` 前被强制杀死、随后又被恢复时会丢失盖章),但那样的子 agent 尚未完成任何工作,恢复毫无价值;而该会话头字段的代价是一次会话格式扩展、一条持久边界上的校验路径、每个折叠消费方(`resolve()`、pty-local、权限展示)里的种子切片优先级逻辑,以及策略状态的第二个存放处。事件方案不改动任何折叠、任何格式、任何消费方。 +- **在子 agent 创建时(任何轮次之外)盖章**:不予采纳。持久化契约在轮次边界提交,因此轮次开始前的裸事件在重新加载时会被当作撕裂尾部截断;会话不变量测试套件会直接判这种追加失败。 +- **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 +- **给每个进程内子 agent 强制设置 `approvalPolicy: 'never'`**:不予采纳。这在今天是事实(没有应答器认领它们),但它会静默排除未来能够服务子 agent 的应答器,并搅浑继承语义;只继承父级的覆盖项既保住 fail-closed 结果,又让每次请求的拒绝原因保持诚实。 +- **把子 agent 的审批请求路由给根会话的编辑器**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。 + +## 后果 + +- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言),并由两个服务各自测试套件中的 `inheritOverride` 契约测试钉住。 +- 盖章写入的覆盖项是子 agent 自己的持久记录:恢复时它像任何一次切换一样被回放;子 agent 之后仍可被独立切换,驱动器不会重新盖章覆盖它(一次性监听器加折叠去重)。 +- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);子 agent 在第一个 `turn/end` 前被强制杀死后,恢复时会丢失盖章(恢复无价值的边角场景,上文已记录);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 +- `dsh-subagent-inprocess` 现在将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..71454af03c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -242,9 +242,23 @@ Approval service that applies session policy before answerers and logs every ask * append commit point. */ async request(req: ApprovalRequest): Promise + +/** + * Stamp the parent's approval-policy OVERRIDE onto a child session through + * the canonical write path — the delegation-inheritance step: a `'never'` + * (headless/CI) parent must not mint children that fall back to a prompting + * default. Only the override chain is copied: an unswitched parent stamps + * nothing, so the child keeps following the LIVE configured default. A + * child whose log (e.g. a fork seed) already folds to the inherited policy + * is left untouched. Callers must append inside an open child turn — a bare + * between-turn event is crash-tail garbage on reload. + * @param parent - the delegating session whose effective override is read. + * @param child - the child session the override is appended to. + */ +inheritOverride(parent: Session, child: Session): void ``` -Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) +Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) @@ -874,9 +888,23 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy + +/** + * Stamp the parent's sandbox-mode OVERRIDE onto a child session through the + * canonical write path — the delegation-inheritance step: a child agent runs + * under the policy its delegating parent was switched to, not under the + * (possibly wider) deployment default. Only the override chain is copied: an + * unswitched parent stamps nothing, so the child keeps following the LIVE + * deployment default. A child whose log (e.g. a fork seed) already folds to + * the inherited mode is left untouched. Callers must append inside an open + * child turn — a bare between-turn event is crash-tail garbage on reload. + * @param parent - the delegating session whose effective override is read. + * @param child - the child session the override is appended to. + */ +inheritOverride(parent: Session, child: Session): void ``` -Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) +Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a04e633377..8cb9018c19 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | diff --git a/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..2fe07b5592 --- /dev/null +++ b/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml @@ -0,0 +1,65 @@ +# Keyless replay counterpart of subagent-inheritance.cordis.yml: the same +# read-only preset and model pin, plus the standard replay swaps (disable the +# key-requiring adapter, passthrough sandbox runner, insert llm-replay). +# Patches do not compose across nested includes, so everything applies together +# over the live tree. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + name: read-only + description: Read all files; every mutation is denied without a per-call approval. + workspace-write: + sandbox: workspace-write + approval: ask + name: workspace-write + description: Write inside the workspace and permitted temporary directories; wider retries require approval. + danger-full-access: + sandbox: danger-full-access + approval: never + name: danger-full-access + description: Full file access without approval prompts. + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + 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. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/subagent-inheritance.cordis.yml b/examples/acp-agent/subagent-inheritance.cordis.yml new file mode 100644 index 0000000000..22d7d75bfa --- /dev/null +++ b/examples/acp-agent/subagent-inheritance.cordis.yml @@ -0,0 +1,44 @@ +# Subagent policy-inheritance snapshot overlay: add a read-only preset to the +# default permission table so a scenario can tighten the parent session below +# the snapshot composition's danger-full-access default before delegating — the +# child's denial then proves the override crossed the delegation boundary. The +# model is pinned to deepseek-v4-flash so this scenario's request headers stay +# uniform with the rest of the recorded sandbox-class corpus. A config patch +# replaces the whole target config, so base fields are restated verbatim. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + name: read-only + description: Read all files; every mutation is denied without a per-call approval. + workspace-write: + sandbox: workspace-write + approval: ask + name: workspace-write + description: Write inside the workspace and permitted temporary directories; wider retries require approval. + danger-full-access: + sandbox: danger-full-access + approval: never + name: danger-full-access + description: Full file access without approval prompts. + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + 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.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..87620c3227 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,6 +38,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) +const SUBAGENT_INHERITANCE_CONFIG = fileURLToPath(new URL('../subagent-inheritance.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -241,6 +242,19 @@ const SCENARIOS: Scenario[] = [ { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, + // Policy inheritance across delegation: the overlay adds a read-only preset, + // the script tightens the PARENT session to it, and the delegated child's + // real write is then denied by the fs fence — proving the parent's override + // crossed the delegation boundary instead of the child escaping to the + // composition default (the subagent policy-inheritance Agent Note). + { + name: 'subagent-sandbox-inheritance', + hasModelTurn: true, + recorded: true, + headerClass: 'subagent-inheritance', + pinsHeader: true, + configPath: SUBAGENT_INHERITANCE_CONFIG, + }, // Unlike ordinary snapshots, this session cwd is outside the platform temp // roots that workspace-write always grants. The overlay points the // deployment fallback at /tmp, so a successful relative write proves the diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json new file mode 100644 index 0000000000..5cfab54a04 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "setConfigOption", + "configId": "permission", + "value": "read-only" + }, + { + "op": "prompt", + "text": "Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl new file mode 100644 index 0000000000..436477f659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl @@ -0,0 +1,238 @@ +{"type":"session","version":0,"id":"895486f2-ab5d-4271-8929-c39098e5cd67","createdAt":1784922514485,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-enaeOs","parentSession":"750b62f4-239a-4ba1-afee-bf078d4cc690","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784922514485,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"sandbox/mode","seq":1,"time":1784922514486,"data":{"mode":"read-only"}} +{"type":"approval/policy","seq":2,"time":1784922514486,"data":{"policy":"ask"}} +{"type":"user/message","seq":3,"time":1784922514486,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":4,"time":1784922514486,"data":{"title":"Use the write tool exactly","messageSeqs":[3],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":5,"time":1784922514488,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1784922514488,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1784922515213,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1784922515213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1784922515307,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1784922515333,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1784922515333,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1784922515333,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1784922515333,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":14,"time":1784922515333,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":15,"time":1784922515358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":16,"time":1784922515358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":17,"time":1784922515358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":18,"time":1784922515358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":19,"time":1784922515384,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1784922515384,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":21,"time":1784922515384,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_path"}}} +{"type":"assistant/chunk","seq":22,"time":1784922515384,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" set"}}} +{"type":"assistant/chunk","seq":23,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":25,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"inher"}}} +{"type":"assistant/chunk","seq":26,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ited"}}} +{"type":"assistant/chunk","seq":27,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":28,"time":1784922515408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1784922515435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":30,"time":1784922515435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"relative"}}} +{"type":"assistant/chunk","seq":31,"time":1784922515435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} +{"type":"assistant/chunk","seq":32,"time":1784922515435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")."}}} +{"type":"assistant/chunk","seq":33,"time":1784922515435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":34,"time":1784922515460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":35,"time":1784922515460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":36,"time":1784922515485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":37,"time":1784922515485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":38,"time":1784922515485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":39,"time":1784922515510,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":40,"time":1784922515510,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":41,"time":1784922515536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":42,"time":1784922515536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":43,"time":1784922515536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":44,"time":1784922515536,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":45,"time":1784922515562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":46,"time":1784922515562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":47,"time":1784922515562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" escalate"}}} +{"type":"assistant/chunk","seq":48,"time":1784922515586,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":49,"time":1784922515587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":50,"time":1784922515587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":51,"time":1784922515587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":52,"time":1784922515587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":53,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":54,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":55,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"EN"}}} +{"type":"assistant/chunk","seq":56,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"IED"}}} +{"type":"assistant/chunk","seq":57,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784922515613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":59,"time":1784922515638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1784922515638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} +{"type":"assistant/chunk","seq":61,"time":1784922515666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":62,"time":1784922515666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":63,"time":1784922515666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":64,"time":1784922515690,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1784922515691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" If"}}} +{"type":"assistant/chunk","seq":66,"time":1784922515691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":67,"time":1784922515691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeds"}}} +{"type":"assistant/chunk","seq":68,"time":1784922515691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":69,"time":1784922515691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":70,"time":1784922515715,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":71,"time":1784922515715,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":72,"time":1784922515715,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":73,"time":1784922515715,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_W"}}} +{"type":"assistant/chunk","seq":74,"time":1784922515715,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RO"}}} +{"type":"assistant/chunk","seq":75,"time":1784922515744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"TE"}}} +{"type":"assistant/chunk","seq":76,"time":1784922515744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\".\n\n"}}} +{"type":"assistant/chunk","seq":77,"time":1784922515744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":78,"time":1784922515744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":79,"time":1784922515745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":80,"time":1784922515769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1784922515769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":82,"time":1784922515769,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":83,"time":1784922515793,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":84,"time":1784922515794,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1784922515844,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":86,"time":1784922515844,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":87,"time":1784922515869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":88,"time":1784922515869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1784922515869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":90,"time":1784922515895,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":91,"time":1784922515895,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":92,"time":1784922515895,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":93,"time":1784922515895,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":94,"time":1784922515922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"inher"}}} +{"type":"assistant/chunk","seq":95,"time":1784922515923,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"ited"}}} +{"type":"assistant/chunk","seq":96,"time":1784922515923,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":97,"time":1784922515923,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1784922515946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":99,"time":1784922515946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1784922515970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":101,"time":1784922515970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1784922515970,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":103,"time":1784922515971,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1784922515996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"This"}}} +{"type":"assistant/chunk","seq":105,"time":1784922515996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":106,"time":1784922516021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":" a"}}} +{"type":"assistant/chunk","seq":107,"time":1784922516021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":" test"}}} +{"type":"assistant/chunk","seq":108,"time":1784922516021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":109,"time":1784922516048,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":110,"time":1784922516048,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1784922516048,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":112,"time":1784922516103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path set to \"inherited.txt\" (relative path). They say if it's denied, don't retry, don't escalate, just reply \"CHILD_DENIED\" and the exact denial marker line. If it succeeds, reply \"CHILD_WROTE\".\n\nLet me do the write tool call."}}}} +{"type":"assistant/chunk","seq":113,"time":1784922516103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"This is a test file.\"}"}}}} +{"type":"assistant/chunk","seq":114,"time":1784922516103,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5757,"outputTokens":143,"cacheReadTokens":0,"reasoningTokens":77}}}} +{"type":"assistant/chunk","seq":115,"time":1784922516104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":116,"time":1784922516104,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path set to \"inherited.txt\" (relative path). They say if it's denied, don't retry, don't escalate, just reply \"CHILD_DENIED\" and the exact denial marker line. If it succeeds, reply \"CHILD_WROTE\".\n\nLet me do the write tool call."},{"type":"tool-call","id":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"This is a test file.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5757,"outputTokens":143,"cacheReadTokens":0,"reasoningTokens":77}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":117,"time":1784922516105,"data":{"turn":1,"step":1,"callId":"call_00_5ZMigFU5LNa6V2hc39cB7511","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"This is a test file.\"}"}} +{"type":"tool/result","seq":118,"time":1784922516114,"data":{"turn":1,"step":1,"callId":"call_00_5ZMigFU5LNa6V2hc39cB7511","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true,"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[117],"surfaceOp":"append"} +{"type":"step/end","seq":119,"time":1784922516121,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":120,"time":1784922516122,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":121,"time":1784922516993,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":122,"time":1784922516993,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":123,"time":1784922517131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":124,"time":1784922517156,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":125,"time":1784922517157,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":126,"time":1784922517157,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":127,"time":1784922517157,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" According"}}} +{"type":"assistant/chunk","seq":128,"time":1784922517157,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":129,"time":1784922517157,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":130,"time":1784922517183,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":131,"time":1784922517183,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":132,"time":1784922517183,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":133,"time":1784922517183,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"If"}}} +{"type":"assistant/chunk","seq":134,"time":1784922517210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":135,"time":1784922517210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":136,"time":1784922517210,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":137,"time":1784922517211,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":138,"time":1784922517211,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":139,"time":1784922517211,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":140,"time":1784922517253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":141,"time":1784922517254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":142,"time":1784922517254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":143,"time":1784922517254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":144,"time":1784922517254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":145,"time":1784922517254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":146,"time":1784922517261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" request"}}} +{"type":"assistant/chunk","seq":147,"time":1784922517262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" escalation"}}} +{"type":"assistant/chunk","seq":148,"time":1784922517262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":";"}}} +{"type":"assistant/chunk","seq":149,"time":1784922517262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":150,"time":1784922517262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":151,"time":1784922517262,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":152,"time":1784922517288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":153,"time":1784922517289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":154,"time":1784922517289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} +{"type":"assistant/chunk","seq":155,"time":1784922517289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":156,"time":1784922517289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":157,"time":1784922517314,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EN"}}} +{"type":"assistant/chunk","seq":158,"time":1784922517315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"IED"}}} +{"type":"assistant/chunk","seq":159,"time":1784922517315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":160,"time":1784922517315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":161,"time":1784922517315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} +{"type":"assistant/chunk","seq":162,"time":1784922517315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":163,"time":1784922517342,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":164,"time":1784922517343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":165,"time":1784922517343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":166,"time":1784922517343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":167,"time":1784922517343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":168,"time":1784922517369,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":169,"time":1784922517370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":170,"time":1784922517370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":171,"time":1784922517370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":172,"time":1784922517370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":173,"time":1784922517370,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":174,"time":1784922517395,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":175,"time":1784922517395,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":176,"time":1784922517395,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":177,"time":1784922517421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":178,"time":1784922517421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":179,"time":1784922517421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":180,"time":1784922517421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EN"}}} +{"type":"assistant/chunk","seq":181,"time":1784922517421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"IED"}}} +{"type":"assistant/chunk","seq":182,"time":1784922517422,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":183,"time":1784922517448,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":184,"time":1784922517448,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":185,"time":1784922517448,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} +{"type":"assistant/chunk","seq":186,"time":1784922517448,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":187,"time":1784922517449,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":188,"time":1784922517449,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":189,"time":1784922517475,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":190,"time":1784922517476,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":191,"time":1784922517476,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":192,"time":1784922517500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":193,"time":1784922517500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":194,"time":1784922517500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":195,"time":1784922517527,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":196,"time":1784922517528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":197,"time":1784922517528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":198,"time":1784922517528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":199,"time":1784922517528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":200,"time":1784922517528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \"["}}} +{"type":"assistant/chunk","seq":201,"time":1784922517553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":202,"time":1784922517553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":203,"time":1784922517553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":204,"time":1784922517553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":205,"time":1784922517580,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" access"}}} +{"type":"assistant/chunk","seq":206,"time":1784922517580,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":207,"time":1784922517582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" under"}}} +{"type":"assistant/chunk","seq":208,"time":1784922517582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":209,"time":1784922517582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-only"}}} +{"type":"assistant/chunk","seq":210,"time":1784922517582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" mode"}}} +{"type":"assistant/chunk","seq":211,"time":1784922517606,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"]\""}}} +{"type":"assistant/chunk","seq":212,"time":1784922517634,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":213,"time":1784922517634,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} +{"type":"assistant/chunk","seq":214,"time":1784922517634,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":215,"time":1784922517634,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":216,"time":1784922517659,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EN"}}} +{"type":"assistant/chunk","seq":217,"time":1784922517660,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"IED"}}} +{"type":"assistant/chunk","seq":218,"time":1784922517660,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" ["}}} +{"type":"assistant/chunk","seq":219,"time":1784922517660,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sand"}}} +{"type":"assistant/chunk","seq":220,"time":1784922517689,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"box"}}} +{"type":"assistant/chunk","seq":221,"time":1784922517689,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":222,"time":1784922517689,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":223,"time":1784922517689,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" access"}}} +{"type":"assistant/chunk","seq":224,"time":1784922517689,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" denied"}}} +{"type":"assistant/chunk","seq":225,"time":1784922517694,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" under"}}} +{"type":"assistant/chunk","seq":226,"time":1784922517716,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":227,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-only"}}} +{"type":"assistant/chunk","seq":228,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" mode"}}} +{"type":"assistant/chunk","seq":229,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"]"}}} +{"type":"assistant/chunk","seq":230,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied. According to the instructions: \"If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result.\"\n\nSo I need to reply with \"CHILD_DENIED\" and the exact denial marker line. The denial marker line from the tool result is: \"[sandbox: file access denied under read-only mode]\""}}}} +{"type":"assistant/chunk","seq":231,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":232,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":76,"outputTokens":108,"cacheReadTokens":5888,"reasoningTokens":90}}}} +{"type":"assistant/chunk","seq":233,"time":1784922517717,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":234,"time":1784922517718,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The write was denied. According to the instructions: \"If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result.\"\n\nSo I need to reply with \"CHILD_DENIED\" and the exact denial marker line. The denial marker line from the tool result is: \"[sandbox: file access denied under read-only mode]\""},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":76,"outputTokens":108,"cacheReadTokens":5888,"reasoningTokens":90}},"sourceEventSeqs":[121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233],"surfaceOp":"append"} +{"type":"step/end","seq":235,"time":1784922517725,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":236,"time":1784922517726,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl new file mode 100644 index 0000000000..39ea848ca8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl @@ -0,0 +1,323 @@ +{"type":"session","version":0,"id":"750b62f4-239a-4ba1-afee-bf078d4cc690","createdAt":1784922511724,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-enaeOs","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1784922511731,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":1,"time":1784922511731,"data":{"preset":"read-only"}} +{"type":"sandbox/mode","seq":2,"time":1784922511731,"data":{"mode":"read-only"}} +{"type":"approval/policy","seq":3,"time":1784922511731,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1784922511731,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1784922511732,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1784922511736,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1784922511736,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1784922512972,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":9,"time":1784922512973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":10,"time":1784922513064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":11,"time":1784922513091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":12,"time":1784922513091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":13,"time":1784922513091,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1784922513118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":15,"time":1784922513118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":16,"time":1784922513118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":17,"time":1784922513118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}} +{"type":"assistant/chunk","seq":18,"time":1784922513118,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":19,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":20,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":21,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":22,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":23,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":24,"time":1784922513145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":25,"time":1784922513172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} +{"type":"assistant/chunk","seq":26,"time":1784922513172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":27,"time":1784922513172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":28,"time":1784922513198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} +{"type":"assistant/chunk","seq":29,"time":1784922513240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":30,"time":1784922513240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":31,"time":1784922513240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":32,"time":1784922513240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":33,"time":1784922513250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":34,"time":1784922513251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":35,"time":1784922513251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1784922513251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":37,"time":1784922513251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":38,"time":1784922513251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":39,"time":1784922513277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":40,"time":1784922513305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":41,"time":1784922513305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":42,"time":1784922513305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":43,"time":1784922513330,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":44,"time":1784922513330,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":45,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":46,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} +{"type":"assistant/chunk","seq":47,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":48,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"inher"}}} +{"type":"assistant/chunk","seq":49,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ited"}}} +{"type":"assistant/chunk","seq":50,"time":1784922513358,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":51,"time":1784922513386,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1784922513411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":53,"time":1784922513412,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":54,"time":1784922513412,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":55,"time":1784922513438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":56,"time":1784922513438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":57,"time":1784922513439,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":58,"time":1784922513439,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":59,"time":1784922513439,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":60,"time":1784922513439,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":61,"time":1784922513465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":62,"time":1784922513465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":63,"time":1784922513465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":64,"time":1784922513466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":65,"time":1784922513466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":66,"time":1784922513491,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":67,"time":1784922513518,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" state"}}} +{"type":"assistant/chunk","seq":68,"time":1784922513518,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":69,"time":1784922513518,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":70,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":71,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":72,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" whether"}}} +{"type":"assistant/chunk","seq":73,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":74,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":75,"time":1784922513545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":76,"time":1784922513578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":77,"time":1784922513578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":78,"time":1784922513578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":79,"time":1784922513578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":80,"time":1784922513598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":81,"time":1784922513598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":82,"time":1784922513598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quoting"}}} +{"type":"assistant/chunk","seq":83,"time":1784922513598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":84,"time":1784922513598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":85,"time":1784922513599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":86,"time":1784922513638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":87,"time":1784922513640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} +{"type":"assistant/chunk","seq":88,"time":1784922513640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":89,"time":1784922513640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":90,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":91,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":92,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":93,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":94,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":95,"time":1784922513651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":96,"time":1784922513652,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":97,"time":1784922513681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":98,"time":1784922513681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":99,"time":1784922513681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":100,"time":1784922513681,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":101,"time":1784922513705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":102,"time":1784922513705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":103,"time":1784922513705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":104,"time":1784922513705,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":105,"time":1784922513731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":106,"time":1784922513731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":107,"time":1784922513731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":108,"time":1784922513731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} +{"type":"assistant/chunk","seq":109,"time":1784922513731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":110,"time":1784922513812,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":111,"time":1784922513812,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":112,"time":1784922513839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":113,"time":1784922513840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1784922513840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":115,"time":1784922513840,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1784922513879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":117,"time":1784922513879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1784922513879,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"Write"}}} +{"type":"assistant/chunk","seq":119,"time":1784922513913,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" inherited"}}} +{"type":"assistant/chunk","seq":120,"time":1784922513913,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":121,"time":1784922513939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" or"}}} +{"type":"assistant/chunk","seq":122,"time":1784922513939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" report"}}} +{"type":"assistant/chunk","seq":123,"time":1784922513966,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" denial"}}} +{"type":"assistant/chunk","seq":124,"time":1784922513966,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1784922513992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":126,"time":1784922513992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":127,"time":1784922513992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":128,"time":1784922513992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":129,"time":1784922514020,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":130,"time":1784922514020,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":131,"time":1784922514020,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":132,"time":1784922514020,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"Use"}}} +{"type":"assistant/chunk","seq":133,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":134,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":135,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" tool"}}} +{"type":"assistant/chunk","seq":136,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":137,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" once"}}} +{"type":"assistant/chunk","seq":138,"time":1784922514045,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":139,"time":1784922514071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":140,"time":1784922514071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":141,"time":1784922514071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" set"}}} +{"type":"assistant/chunk","seq":142,"time":1784922514072,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":143,"time":1784922514072,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":144,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":145,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" relative"}}} +{"type":"assistant/chunk","seq":146,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" path"}}} +{"type":"assistant/chunk","seq":147,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" inherited"}}} +{"type":"assistant/chunk","seq":148,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":149,"time":1784922514098,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" ("}}} +{"type":"assistant/chunk","seq":150,"time":1784922514137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"never"}}} +{"type":"assistant/chunk","seq":151,"time":1784922514137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" an"}}} +{"type":"assistant/chunk","seq":152,"time":1784922514137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" absolute"}}} +{"type":"assistant/chunk","seq":153,"time":1784922514137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" path"}}} +{"type":"assistant/chunk","seq":154,"time":1784922514137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":")"}}} +{"type":"assistant/chunk","seq":155,"time":1784922514150,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":156,"time":1784922514151,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":157,"time":1784922514151,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" escaped"}}} +{"type":"assistant/chunk","seq":158,"time":1784922514151,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":159,"time":1784922514151,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" If"}}} +{"type":"assistant/chunk","seq":160,"time":1784922514151,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":161,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":162,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":163,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" denied"}}} +{"type":"assistant/chunk","seq":164,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":165,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" do"}}} +{"type":"assistant/chunk","seq":166,"time":1784922514177,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" not"}}} +{"type":"assistant/chunk","seq":167,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" ret"}}} +{"type":"assistant/chunk","seq":168,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"ry"}}} +{"type":"assistant/chunk","seq":169,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":170,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" do"}}} +{"type":"assistant/chunk","seq":171,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" not"}}} +{"type":"assistant/chunk","seq":172,"time":1784922514206,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" request"}}} +{"type":"assistant/chunk","seq":173,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" escalation"}}} +{"type":"assistant/chunk","seq":174,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":175,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":176,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":177,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":178,"time":1784922514230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" single"}}} +{"type":"assistant/chunk","seq":179,"time":1784922514257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":180,"time":1784922514258,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":181,"time":1784922514258,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":182,"time":1784922514258,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"_D"}}} +{"type":"assistant/chunk","seq":183,"time":1784922514284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"EN"}}} +{"type":"assistant/chunk","seq":184,"time":1784922514284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"IED"}}} +{"type":"assistant/chunk","seq":185,"time":1784922514284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":186,"time":1784922514285,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":187,"time":1784922514285,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" exact"}}} +{"type":"assistant/chunk","seq":188,"time":1784922514285,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" denial"}}} +{"type":"assistant/chunk","seq":189,"time":1784922514310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" marker"}}} +{"type":"assistant/chunk","seq":190,"time":1784922514310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" line"}}} +{"type":"assistant/chunk","seq":191,"time":1784922514310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" from"}}} +{"type":"assistant/chunk","seq":192,"time":1784922514310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":193,"time":1784922514310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" tool"}}} +{"type":"assistant/chunk","seq":194,"time":1784922514337,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":195,"time":1784922514337,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":196,"time":1784922514337,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" If"}}} +{"type":"assistant/chunk","seq":197,"time":1784922514337,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" it"}}} +{"type":"assistant/chunk","seq":198,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" succeeds"}}} +{"type":"assistant/chunk","seq":199,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":","}}} +{"type":"assistant/chunk","seq":200,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":201,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":202,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":203,"time":1784922514364,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" single"}}} +{"type":"assistant/chunk","seq":204,"time":1784922514390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":205,"time":1784922514392,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":206,"time":1784922514392,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":207,"time":1784922514392,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"_W"}}} +{"type":"assistant/chunk","seq":208,"time":1784922514417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"RO"}}} +{"type":"assistant/chunk","seq":209,"time":1784922514417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"TE"}}} +{"type":"assistant/chunk","seq":210,"time":1784922514417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":211,"time":1784922514417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":212,"time":1784922514444,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":213,"time":1784922514476,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate a specific task to a child agent\n2. The child agent should try to write to a file called \"inherited.txt\" using the write tool\n3. After the subagent returns, I should state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one\n4. Then reply with \"PARENT_DONE\"\n\nLet me create the subagent task."}}}} +{"type":"assistant/chunk","seq":214,"time":1784922514476,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","arguments":"{\"description\": \"Write inherited.txt or report denial\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.\"}"}}}} +{"type":"assistant/chunk","seq":215,"time":1784922514476,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5833,"outputTokens":244,"cacheReadTokens":0,"reasoningTokens":101}}}} +{"type":"assistant/chunk","seq":216,"time":1784922514476,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":217,"time":1784922514478,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate a specific task to a child agent\n2. The child agent should try to write to a file called \"inherited.txt\" using the write tool\n3. After the subagent returns, I should state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one\n4. Then reply with \"PARENT_DONE\"\n\nLet me create the subagent task."},{"type":"tool-call","id":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","arguments":"{\"description\": \"Write inherited.txt or report denial\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5833,"outputTokens":244,"cacheReadTokens":0,"reasoningTokens":101}},"sourceEventSeqs":[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,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],"surfaceOp":"append"} +{"type":"tool/call","seq":218,"time":1784922514478,"data":{"turn":1,"step":1,"callId":"call_00_dlAh2r8m0Vign3VuxtqV5048","name":"subagent","arguments":"{\"description\": \"Write inherited.txt or report denial\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.\"}"}} +{"type":"tool/result","seq":219,"time":1784922517732,"data":{"turn":1,"step":1,"callId":"call_00_dlAh2r8m0Vign3VuxtqV5048","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false},"sourceEventSeqs":[218],"surfaceOp":"append"} +{"type":"step/end","seq":220,"time":1784922517739,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":221,"time":1784922517739,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":222,"time":1784922518650,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":223,"time":1784922518650,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":224,"time":1784922518776,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":225,"time":1784922518792,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":226,"time":1784922518795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":227,"time":1784922518795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":228,"time":1784922518819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":229,"time":1784922518820,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":230,"time":1784922518820,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":231,"time":1784922518820,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":232,"time":1784922518843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EN"}}} +{"type":"assistant/chunk","seq":233,"time":1784922518844,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"IED"}}} +{"type":"assistant/chunk","seq":234,"time":1784922518844,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ["}}} +{"type":"assistant/chunk","seq":235,"time":1784922518844,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sand"}}} +{"type":"assistant/chunk","seq":236,"time":1784922518844,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":237,"time":1784922518845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":238,"time":1784922518868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":239,"time":1784922518868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" access"}}} +{"type":"assistant/chunk","seq":240,"time":1784922518868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":241,"time":1784922518868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" under"}}} +{"type":"assistant/chunk","seq":242,"time":1784922518868,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":243,"time":1784922518870,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-only"}}} +{"type":"assistant/chunk","seq":244,"time":1784922518895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" mode"}}} +{"type":"assistant/chunk","seq":245,"time":1784922518895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"]"}}} +{"type":"assistant/chunk","seq":246,"time":1784922518923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\".\n\n"}}} +{"type":"assistant/chunk","seq":247,"time":1784922518923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":248,"time":1784922518923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":249,"time":1784922518924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":250,"time":1784922518924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":251,"time":1784922518945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":252,"time":1784922518947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":253,"time":1784922518948,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":254,"time":1784922518948,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" State"}}} +{"type":"assistant/chunk","seq":255,"time":1784922518948,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":256,"time":1784922518970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":257,"time":1784922518970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} +{"type":"assistant/chunk","seq":258,"time":1784922518970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sentence"}}} +{"type":"assistant/chunk","seq":259,"time":1784922518971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" whether"}}} +{"type":"assistant/chunk","seq":260,"time":1784922518971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":261,"time":1784922518971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":262,"time":1784922518995,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":263,"time":1784922518997,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denied"}}} +{"type":"assistant/chunk","seq":264,"time":1784922518997,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":265,"time":1784922518998,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":266,"time":1784922519021,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":267,"time":1784922519021,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":268,"time":1784922519022,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":269,"time":1784922519022,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quoting"}}} +{"type":"assistant/chunk","seq":270,"time":1784922519022,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":271,"time":1784922519022,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" denial"}}} +{"type":"assistant/chunk","seq":272,"time":1784922519047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" marker"}}} +{"type":"assistant/chunk","seq":273,"time":1784922519072,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":274,"time":1784922519073,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":275,"time":1784922519073,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":276,"time":1784922519073,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":277,"time":1784922519074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":278,"time":1784922519074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":279,"time":1784922519075,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":280,"time":1784922519102,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":281,"time":1784922519103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":282,"time":1784922519103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":283,"time":1784922519103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":284,"time":1784922519103,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":285,"time":1784922519103,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":286,"time":1784922519123,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" child"}}} +{"type":"assistant/chunk","seq":287,"time":1784922519125,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":288,"time":1784922519126,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" denied"}}} +{"type":"assistant/chunk","seq":289,"time":1784922519126,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":290,"time":1784922519150,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":291,"time":1784922519152,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" sand"}}} +{"type":"assistant/chunk","seq":292,"time":1784922519152,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"box"}}} +{"type":"assistant/chunk","seq":293,"time":1784922519152,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" with"}}} +{"type":"assistant/chunk","seq":294,"time":1784922519174,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":295,"time":1784922519176,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" marker"}}} +{"type":"assistant/chunk","seq":296,"time":1784922519176,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":297,"time":1784922519200,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":298,"time":1784922519226,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"["}}} +{"type":"assistant/chunk","seq":299,"time":1784922519226,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sand"}}} +{"type":"assistant/chunk","seq":300,"time":1784922519228,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"box"}}} +{"type":"assistant/chunk","seq":301,"time":1784922519228,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":302,"time":1784922519228,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":303,"time":1784922519228,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" access"}}} +{"type":"assistant/chunk","seq":304,"time":1784922519252,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" denied"}}} +{"type":"assistant/chunk","seq":305,"time":1784922519252,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" under"}}} +{"type":"assistant/chunk","seq":306,"time":1784922519253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":307,"time":1784922519253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-only"}}} +{"type":"assistant/chunk","seq":308,"time":1784922519253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" mode"}}} +{"type":"assistant/chunk","seq":309,"time":1784922519253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"]"}}} +{"type":"assistant/chunk","seq":310,"time":1784922519279,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`.\n\n"}}} +{"type":"assistant/chunk","seq":311,"time":1784922519279,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":312,"time":1784922519279,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":313,"time":1784922519280,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":314,"time":1784922519280,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":315,"time":1784922519281,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned with \"CHILD_DENIED [sandbox: file access denied under read-only mode]\".\n\nNow I need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker\n2. Reply with \"PARENT_DONE\""}}}} +{"type":"assistant/chunk","seq":316,"time":1784922519281,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The child was denied by the sandbox with the marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}}}} +{"type":"assistant/chunk","seq":317,"time":1784922519281,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":92,"cacheReadTokens":6016,"reasoningTokens":61}}}} +{"type":"assistant/chunk","seq":318,"time":1784922519281,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":319,"time":1784922519282,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned with \"CHILD_DENIED [sandbox: file access denied under read-only mode]\".\n\nNow I need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker\n2. Reply with \"PARENT_DONE\""},{"type":"text","text":"The child was denied by the sandbox with the marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":92,"cacheReadTokens":6016,"reasoningTokens":61}},"sourceEventSeqs":[222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318],"surfaceOp":"append"} +{"type":"step/end","seq":320,"time":1784922519287,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":321,"time":1784922519287,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl new file mode 100644 index 0000000000..d95775322d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl @@ -0,0 +1,200 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"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":"read-only","name":"read-only","description":"Read all files; every mutation is denied without a per-call approval."},{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only","description":"Read all files; every mutation is denied without a per-call approval."},{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the subagent tool exactly","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_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":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"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":" delegate"}}}} +{"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":" task"}}}} +{"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":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} +{"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":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} +{"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":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"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":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" called"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ited"}}}} +{"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":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" state"}}}} +{"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":" whether"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" denied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"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":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quoting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" denial"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" marker"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"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":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"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":"\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" task"}}}} +{"jsonrpc":"2.0","method":"session/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_dlAh2r8m0Vign3VuxtqV5048","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Write inherited.txt or report denial","prompt":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dlAh2r8m0Vign3VuxtqV5048","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" 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":"CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"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":"EN"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"IED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_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"}}}} +{"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":":"}}}} +{"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":" access"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" denied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" under"}}}} +{"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":"-only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mode"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"]"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"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":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" State"}}}} +{"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":" whether"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" denied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"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":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quoting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" denial"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" marker"}}}} +{"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":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"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":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" child"}}}} +{"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":" denied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" sand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"box"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" marker"}}}} +{"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":"["}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"sand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"box"}}}} +{"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":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" access"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" denied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" under"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" mode"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"]"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`.\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"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":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md new file mode 100644 index 0000000000..e3437ad61a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md @@ -0,0 +1,24 @@ +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}}. 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. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + + + +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. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json new file mode 100644 index 0000000000..61d317e2f8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json @@ -0,0 +1,546 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). 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": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "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." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "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." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "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?, provider?, 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "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." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..ff1d4df304 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -150,6 +150,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async request(req: ApprovalRequest): Promise', jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */', }, + { + signature: 'inheritOverride(parent: Session, child: Session): void', + jsDoc: '/**\n * Stamp the parent\'s approval-policy OVERRIDE onto a child session through\n * the canonical write path — the delegation-inheritance step: a `\'never\'`\n * (headless/CI) parent must not mint children that fall back to a prompting\n * default. Only the override chain is copied: an unswitched parent stamps\n * nothing, so the child keeps following the LIVE configured default. A\n * child whose log (e.g. a fork seed) already folds to the inherited policy\n * is left untouched. Callers must append inside an open child turn — a bare\n * between-turn event is crash-tail garbage on reload.\n * @param parent - the delegating session whose effective override is read.\n * @param child - the child session the override is appended to.\n */', + }, ], }, { @@ -442,6 +446,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', }, + { + signature: 'inheritOverride(parent: Session, child: Session): void', + jsDoc: '/**\n * Stamp the parent\'s sandbox-mode OVERRIDE onto a child session through the\n * canonical write path — the delegation-inheritance step: a child agent runs\n * under the policy its delegating parent was switched to, not under the\n * (possibly wider) deployment default. Only the override chain is copied: an\n * unswitched parent stamps nothing, so the child keeps following the LIVE\n * deployment default. A child whose log (e.g. a fork seed) already folds to\n * the inherited mode is left untouched. Callers must append inside an open\n * child turn — a bare between-turn event is crash-tail garbage on reload.\n * @param parent - the delegating session whose effective override is read.\n * @param child - the child session the override is appended to.\n */', + }, ], }, { diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 783b338acf..9226c35529 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -17,6 +17,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. +- `ctx.sandboxPolicy.inheritOverride(parent, child)` — the delegation-inheritance step: stamps the parent session's effective override (never the deployment default) onto a child session through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver calls it inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 23a205e60c..ba2810bd53 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,7 +19,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import { effectiveSandboxMode } from './session-mode.ts' +import { effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' @@ -104,6 +104,24 @@ export class SandboxPolicyService extends Service { workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } + + /** + * Stamp the parent's sandbox-mode OVERRIDE onto a child session through the + * canonical write path — the delegation-inheritance step: a child agent runs + * under the policy its delegating parent was switched to, not under the + * (possibly wider) deployment default. Only the override chain is copied: an + * unswitched parent stamps nothing, so the child keeps following the LIVE + * deployment default. A child whose log (e.g. a fork seed) already folds to + * the inherited mode is left untouched. Callers must append inside an open + * child turn — a bare between-turn event is crash-tail garbage on reload. + * @param parent - the delegating session whose effective override is read. + * @param child - the child session the override is appended to. + */ + inheritOverride(parent: Session, child: Session): void { + const inherited = effectiveSandboxMode(parent.events) + if (inherited === undefined || effectiveSandboxMode(child.events) === inherited) return + setSandboxMode(child, inherited) + } } export default SandboxPolicyService diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index cd81caa6b4..7ced415377 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -142,3 +142,47 @@ describe('the sandbox/mode session kit', () => { expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) }) + +describe('inheritOverride (parent → child stamping)', () => { + const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode') + + it('stamps the parent LAST override onto the child through the canonical write path', async () => { + const ctx = await mounted() + const parent = session('sess-inherit-parent') + const child = session('sess-inherit-child') + setSandboxMode(parent, 'workspace-write') + setSandboxMode(parent, 'read-only') + + ctx.sandboxPolicy.inheritOverride(parent, child) + + const stamped = modeEvents(child) + expect(stamped).toHaveLength(1) + expect(stamped[0]?.data).toEqual({ mode: 'read-only' }) + }) + + it('appends NOTHING when the parent never switched (the deployment default must stay live)', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + const parent = session('sess-inherit-default-parent') + const child = session('sess-inherit-default-child') + + ctx.sandboxPolicy.inheritOverride(parent, child) + + // No event — a resumed child keeps following whatever the deployment + // default is THEN, instead of a frozen copy of today's default. + expect(child.events).toHaveLength(0) + }) + + it('skips the append when the child already folds to the inherited mode (fork-seed dedup)', async () => { + const ctx = await mounted() + const parent = session('sess-inherit-dedup-parent') + const child = session('sess-inherit-dedup-child') + setSandboxMode(parent, 'read-only') + // A fork seed can already carry the parent's switch; stamping again would + // append a redundant event on every delegation. + setSandboxMode(child, 'read-only') + + ctx.sandboxPolicy.inheritOverride(parent, child) + + expect(modeEvents(child)).toHaveLength(1) + }) +}) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index e24df08af5..b41e1acdfa 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -16,6 +16,8 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. +The child also inherits the parent's session POLICY overrides: a one-shot `agent/prompt-submit` listener installed during setup stamps the parent's effective `sandbox/mode` and `approval/policy` overrides onto the child through `ctx.sandboxPolicy.inheritOverride` / `ctx.approval.inheritOverride` (both consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request, and its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing; only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each stamp folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 69b573ecdd..f78e944b02 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -30,22 +30,28 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e83b397bb5..b81725ce80 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -14,6 +14,11 @@ import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReaso import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve +// to the policy services when composed — the driver consumes both +// opportunistically (the documented `ctx.get` pattern), never as a hard dep. +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' import { attachStructuredRuntime, type StructuredAttachment, @@ -104,6 +109,20 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } + // Policy inheritance: stamp the parent's sandbox/approval OVERRIDES onto + // the child once, anchored inside the child's FIRST turn (prompt-submit + // runs after turn/start, before prompt assembly) — a bare between-turn + // append would be crash-tail garbage on reload, and stamping here also + // orders the override after any stale switch a fork seed carried, so the + // ordinary last-event-wins fold resolves it. One-shot: later turns must + // not re-stamp over a switch the child made itself. Both services are + // consumed opportunistically — without them, delegation stays policy-free. + const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => { + disposeInherit() + parent.ctx.get('sandboxPolicy')?.inheritOverride(parent.session, childAgent.session) + parent.ctx.get('approval')?.inheritOverride(parent.session, childAgent.session) + return next() + }) } const flags = { cancelled: false } diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts new file mode 100644 index 0000000000..6ddcc4f4fb --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -0,0 +1,403 @@ +/** + * Policy inheritance from parent to in-process child agents, proven against + * the REAL enforcement wall: a real loop drives a scripted mock MODEL whose + * children hit the real `dsh-fs-sandbox` fence through the real `write` tool, + * and every claim is asserted on physical facts — does the file exist on + * disk, what denial text landed in the child's tool result. Nothing here asks + * the policy service what it WOULD do; the child either writes or is denied. + * + * Red/green anchor for the delegation-bypass gap: a parent switched to + * `read-only` must not mint children that run under the (wider) deployment + * default. The stamping design is itself pinned by the mounted session + * invariants: an implementation that appends the inherited override OUTSIDE + * the child's first turn fails these suites through the turn-enclosure check. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]' + +let workspace: string + +beforeEach(async () => { + // realpath: macOS tmpdir is symlinked (/var → /private/var); resolve once so + // path assertions and the fence's canonicalization agree on one spelling. + workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-'))) +}) +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }) +}) + +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + +/** + * The walled composition: real loop + real sandbox-policy home + the real + * confining filesystem backend + the real `write` tool + the approval seam + * (mounted with NO answerer — the in-process child reality). The deployment + * default is deliberately WIDER (`workspace-write`) than the parent's staged + * `read-only` override, so a child that fails to inherit visibly escapes. + * + * The script array is taken by reference and filled by each test AFTER the + * parent exists, so scripted side-effect entries can close over it. + */ +async function setupWalled(script: Script) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await mountInvariants(ctx) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) + await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) + await ctx.plugin(ToolFs) + await ctx.plugin(ApprovalService) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }, { cwd: workspace }) + return { ctx, parent } +} + +/** Bare composition: no sandbox, no fs, no approval — delegation must not care. */ +async function setupBare(script: Script) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await mountInvariants(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +/** + * Register the delegation scratch tool: delegating from INSIDE an open parent + * turn is exactly the real tool-subagent shape, and it is what makes the + * "user switched while idle, model delegates in the very next turn" fork + * timing constructible (the post-seed switch lives in the still-open turn). + * `fork: true` seeds the child with the caller's completed-turn prefix, + * mirroring the fork provider's slice. + */ +function registerDelegate(ctx: Context, captured: Agent[]): void { + ctx.tools.register(defineTool({ + name: 'delegate', + description: 'delegate a task to an in-process child (test scaffold)', + parameters: { fork: { type: 'boolean', description: 'seed the child with the completed-turn prefix' } }, + async execute(args, exec): Promise { + const caller = exec.agent + if (caller === undefined) throw new Error('delegate scaffold requires a calling agent') + const events = caller.session.events + const lastEnd = events.findLast(e => e.type === 'turn/end') + const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1) + const run = await startInProcessRun( + { prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal }, + args.fork === true && seed.length > 0 ? { seed } : {}, + ) + captured.push(run.localAgent as Agent) + const result = await run.result + await run.dispose() + return [{ type: 'text', text: `child:${result.stopReason}` }] + }, + })) +} + +/** All tool/result texts in a session log, in order. */ +function toolResultTexts(agent: Agent): string[] { + return agent.session.events + .filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + .map(e => e.data.content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('')) +} + +/** Count the policy-override events in a session log. */ +function overrideEvents(agent: Agent): { sandbox: number; approval: number } { + const events = agent.session.events + return { + sandbox: events.filter(e => e.type === 'sandbox/mode').length, + approval: events.filter(e => e.type === 'approval/policy').length, + } +} + +function spawnRequest(parent: Agent) { + return { + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + signal: new AbortController().signal, + } +} + +describe('sandbox-mode inheritance against the real fs fence', () => { + it('a SPAWN child of a read-only parent is denied by the real fence (no file on disk)', async () => { + const script: Script = [] + const { ctx, parent } = await setupWalled(script) + const blocked = join(workspace, 'spawn-blocked.txt') + script.push( + // The switch is staged INSIDE a parent turn — the same turn-enclosed + // anchoring every real switch path (ACP pending switches) uses. + () => { + setSandboxMode(parent.session, 'read-only') + setApprovalPolicy(parent.session, 'never') + return textResponse('staged') + }, + toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), + textResponse('child done'), + ) + parent.send([{ type: 'text', text: 'stage the session policy' }]) + await parent.whenIdle() + const parentLogLength = parent.session.events.length + + const run = await startInProcessRun(spawnRequest(parent), {}) + const result = await run.result + const child = run.localAgent as Agent + + // The physical fact: the write never reached the disk. + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + // The model-visible fact: the child saw the read-only denial marker. + expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) + expect(result.stopReason).toBe('completed') + + // The stamped override is the child's OWN durable, turn-enclosed record: + // after turn/start, before the first model request snapshot. + const events = child.session.events + const turnStart = events.findIndex(e => e.type === 'turn/start') + const mode = events.findIndex(e => e.type === 'sandbox/mode') + const policy = events.findIndex(e => e.type === 'approval/policy') + const header = events.findIndex(e => e.type === 'request/header') + expect(mode).toBeGreaterThan(turnStart) + expect(policy).toBeGreaterThan(turnStart) + expect(header).toBeGreaterThan(mode) + // What the enforcing families resolve for the child, end to end. + expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') + // Inheritance reads the parent log, never writes it. + expect(parent.session.events.length).toBe(parentLogLength) + + await run.dispose() + }) + + it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => { + const script: Script = [] + const captured: Agent[] = [] + const { ctx, parent } = await setupWalled(script) + registerDelegate(ctx, captured) + const blocked = join(workspace, 'fork-blocked.txt') + script.push( + // Turn 1: the OLD, wider switch — this one lands in the fork seed. + () => { + setSandboxMode(parent.session, 'workspace-write') + return textResponse('turn one') + }, + // Turn 2: the user tightened to read-only, then the model delegates in + // the SAME turn — the switch is in the log but past the seed slice. + () => { + setSandboxMode(parent.session, 'read-only') + return toolCallResponse('d-fork', 'delegate', { fork: true }) + }, + toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), + textResponse('fork child done'), + textResponse('turn two done'), + ) + parent.send([{ type: 'text', text: 'turn one' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'turn two: delegate' }]) + await parent.whenIdle() + + const child = captured[0] as Agent + // The seed really carried the stale workspace-write switch… + expect(child.session.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) + // …and the newest parent state still won, on disk and in resolution. + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) + expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') + }) + + it('a FORK child whose seed already folds to the parent mode gets NO duplicate stamp (guard)', async () => { + const script: Script = [] + const captured: Agent[] = [] + const { ctx, parent } = await setupWalled(script) + registerDelegate(ctx, captured) + const blocked = join(workspace, 'fork-dedup-blocked.txt') + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('turn one') + }, + toolCallResponse('d-fork', 'delegate', { fork: true }), + toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), + textResponse('fork child done'), + textResponse('turn two done'), + ) + parent.send([{ type: 'text', text: 'turn one' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'turn two: delegate' }]) + await parent.whenIdle() + + const child = captured[0] as Agent + // The seed-carried override keeps enforcing… + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') + // …and inheritance did not append a redundant copy on top of it. + expect(overrideEvents(child).sandbox).toBe(1) + }) + + it('a GRANDCHILD inherits through the chain (child delegates again)', async () => { + const script: Script = [] + const captured: Agent[] = [] + const { ctx, parent } = await setupWalled(script) + registerDelegate(ctx, captured) + const blocked = join(workspace, 'grandchild-blocked.txt') + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + toolCallResponse('d-child', 'delegate', { fork: false }), + // Child immediately delegates the write to a grandchild. + toolCallResponse('d-grandchild', 'delegate', { fork: false }), + toolCallResponse('g-write', 'write', { file_path: blocked, content: 'escaped' }), + textResponse('grandchild done'), + textResponse('child done'), + textResponse('parent done'), + ) + parent.send([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'delegate twice' }]) + await parent.whenIdle() + + expect(captured).toHaveLength(2) + const grandchild = captured[1] as Agent + expect(grandchild.session.header.delegationDepth).toBe(2) + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(toolResultTexts(grandchild).join('\n')).toContain(READ_ONLY_DENIAL) + expect(ctx.sandboxPolicy.resolve({ session: grandchild.session }).mode).toBe('read-only') + }) +}) + +describe('inheritance guards (must hold before AND after the fix)', () => { + it('a child of an unswitched parent runs under the live deployment default, with ZERO stamped events', async () => { + const script: Script = [] + const { parent } = await setupWalled(script) + const allowed = join(workspace, 'default-allowed.txt') + script.push( + toolCallResponse('c-write', 'write', { file_path: allowed, content: 'fine' }), + textResponse('child done'), + ) + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + const child = run.localAgent as Agent + + // workspace-write (the deployment default) really allowed the write… + expect(await readFile(allowed, 'utf8')).toBe('fine') + // …and nothing froze that default into the child log. + expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) + + await run.dispose() + }) + + it('delegation works unchanged when no sandbox/approval services are composed at all', async () => { + const script: Script = [] + const { parent } = await setupBare(script) + script.push(textResponse('bare child answer')) + + const run = await startInProcessRun(spawnRequest(parent), {}) + const result = await run.result + const child = run.localAgent as Agent + + expect(result.stopReason).toBe('completed') + expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) + + await run.dispose() + }) +}) + +describe('what a blocked child experiences', () => { + it('an inherited "never" policy is stated in the child FIRST request system prompt', async () => { + const script: Script = [] + const { parent } = await setupWalled(script) + script.push( + () => { + setApprovalPolicy(parent.session, 'never') + return textResponse('staged') + }, + textResponse('child done'), + ) + parent.send([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + const child = run.localAgent as Agent + + // Model-visible ⟺ logged: the child was TOLD up front not to request + // escalation, in the very first request it ever saw. + const header = child.session.events.find((e): e is SessionEvent<'request/header'> => e.type === 'request/header') + expect(header?.data.header.system).toContain('Approval prompts are disabled') + + await run.dispose() + }) + + it('a denied child that retries with sandbox_permissions fails closed on the REAL escalation gate', async () => { + const script: Script = [] + const { parent } = await setupWalled(script) + const blocked = join(workspace, 'escalation-blocked.txt') + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + // First attempt: denied by the fence. + toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), + // One-shot escalation retry, exactly as the denial hint teaches — the + // approval seam is mounted but NO answerer owns an in-process child. + toolCallResponse('c-escalate', 'write', { + file_path: blocked, + content: 'escaped', + sandbox_permissions: 'workspace-write', + justification: 'the test child wants to write inside the workspace', + }), + textResponse('child gave up'), + ) + parent.send([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + + const run = await startInProcessRun(spawnRequest(parent), {}) + const result = await run.result + const child = run.localAgent as Agent + + // Nothing ever reached the disk — not the first attempt, not the retry. + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + const results = toolResultTexts(child) + expect(results[0]).toContain(READ_ONLY_DENIAL) + // The child's escalation resolves through the real approval waterfall to + // the distinct fail-closed reason — the honest "report upward" signal. + expect(results[1]).toContain('no approval channel is available') + expect(result.stopReason).toBe('completed') + + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 02fd8e53d0..22c00b3a94 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -32,8 +32,14 @@ { "path": "../../core/tools" }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/user-approval" } ] } diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 77b368fac3..0ac341b6b4 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.inheritOverride(parent, child)` stamps a parent session's override (never the configured default) onto a child session through that write path — the in-process subagent driver calls it inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 329fad33a7..be059577e5 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -326,6 +326,24 @@ export class ApprovalService extends Service { return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask' } + /** + * Stamp the parent's approval-policy OVERRIDE onto a child session through + * the canonical write path — the delegation-inheritance step: a `'never'` + * (headless/CI) parent must not mint children that fall back to a prompting + * default. Only the override chain is copied: an unswitched parent stamps + * nothing, so the child keeps following the LIVE configured default. A + * child whose log (e.g. a fork seed) already folds to the inherited policy + * is left untouched. Callers must append inside an open child turn — a bare + * between-turn event is crash-tail garbage on reload. + * @param parent - the delegating session whose effective override is read. + * @param child - the child session the override is appended to. + */ + inheritOverride(parent: Session, child: Session): void { + const inherited = effectiveApprovalPolicy(parent.events) + if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return + setApprovalPolicy(child, inherited) + } + /** * Dispatch the waterfall, contained and raced against the request signal. * @param req - the borrowed public request. diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index fe2643cb3f..83661a5d2b 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -576,3 +576,46 @@ describe('approval policy (the approval/policy fold)', () => { expect(afterDispose.injected).toEqual([]) }) }) + +describe('inheritOverride (parent → child stamping)', () => { + const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy') + + function bareSession(id: string): Session { + return new Session(SessionId(id)) + } + + it('stamps the parent LAST override onto the child through the canonical write path', async () => { + const ctx = await mounted() + const parent = bareSession('sess-appr-inherit-parent') + const child = bareSession('sess-appr-inherit-child') + setApprovalPolicy(parent, 'never') + + ctx.approval.inheritOverride(parent, child) + + const stamped = policyEvents(child) + expect(stamped).toHaveLength(1) + expect(stamped[0]?.data).toEqual({ policy: 'never' }) + }) + + it('appends NOTHING when the parent never switched (the configured default must stay live)', async () => { + const ctx = await mounted() + const parent = bareSession('sess-appr-default-parent') + const child = bareSession('sess-appr-default-child') + + ctx.approval.inheritOverride(parent, child) + + expect(child.events).toHaveLength(0) + }) + + it('skips the append when the child already folds to the inherited policy (fork-seed dedup)', async () => { + const ctx = await mounted() + const parent = bareSession('sess-appr-dedup-parent') + const child = bareSession('sess-appr-dedup-child') + setApprovalPolicy(parent, 'never') + setApprovalPolicy(child, 'never') + + ctx.approval.inheritOverride(parent, child) + + expect(policyEvents(child)).toHaveLength(1) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968891f28d..95540aa9f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3138,12 +3138,18 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3153,9 +3159,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From 6fa2377e34a00ffa8a4c3c7ac19deff25cae48a1 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 10:51:25 +0800 Subject: [PATCH 02/61] subagent: capture overrides at delegation; stamp ahead of prompt vetoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (ds-review-bot on #623): - Capture-at-delegation: the driver now reads overrideOf(parent.session) for both knobs synchronously before its first await, and the prompt-submit listener stamps those captured values — a parent switch racing the child's asynchronous creation belongs to the parent's future, not the child. The inheritOverride(parent, child) service method is split into its two halves (overrideOf / stampOverride) accordingly. - Veto safety: the one-shot prompt-submit listener registers with prepend: true, so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the child's first turn without the durable stamp. Both regressions are pinned red-first in inheritance.spec.ts: the delegation-vs-late-switch race (delegate tool flips the caller wider while the creation transaction is pending) and a blocking prompt-submit listener (stamp survives a promptless first turn). Service contract tests renamed to the split API; READMEs and the bilingual Agent Note updated. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 6 +- ...26-07-25-subagent-policy-inheritance.zh.md | 6 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 39 +++++---- .../sandbox-policy/tests/policy.spec.ts | 37 ++++----- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 42 ++++++---- .../tests/inheritance.spec.ts | 82 ++++++++++++++++++- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 30 ++++--- .../ui/user-approval/tests/approval.spec.ts | 31 +++---- 12 files changed, 192 insertions(+), 91 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index ab8cf722de..14cc3d747c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: bc5eb5b17ce34872db50b4cf848f6a1784ede0fc -2026-07-25-subagent-policy-inheritance.zh.md: 3498b1b01987c44156b922fe0dcdd6223495fed6 +2026-07-25-subagent-policy-inheritance.md: 42196f9b23b9bf31a1f441e9db900493f08e8094 +2026-07-25-subagent-policy-inheritance.zh.md: 3ad2bd3dde913850c0b7726a8b650d4f7f75ceda diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index bc5eb5b17c..42196f9b23 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -12,8 +12,8 @@ Session policy overrides are per-session log folds: the effective sandbox mode i The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) snapshots the parent's policy overrides at delegation and stamps them onto the child as ordinary log events inside the child's FIRST turn: -- **Read at creation, write at first `agent/prompt-submit`.** The driver installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. -- **Only the override chain is copied, through the canonical write paths.** `SandboxPolicyService.inheritOverride(parent, child)` and `ApprovalService.inheritOverride(parent, child)` each fold the parent's FULL live log (not the fork seed), append via `setSandboxMode`/`setApprovalPolicy` only when the parent has an override the child does not already fold to, and never copy the deployment default — an unswitched parent stamps nothing, so a resumed child keeps following the LIVE default. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. +- **Capture synchronously at delegation, stamp at first `agent/prompt-submit`.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window, PREPENDED so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the first turn without the stamp. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. +- **Only the override chain is copied, through the canonical write paths.** `overrideOf(session)` is the fold alone — never the deployment/configured default — so an unswitched parent stamps nothing and a resumed child keeps following the LIVE default; `stampOverride(child, value)` appends via `setSandboxMode`/`setApprovalPolicy` unless the child already folds to the value. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. - **Fork stale-seed precedence falls out of log order.** The stamped event lands after any switch the seed carried, so the existing last-event-wins fold resolves the child's mode with no new precedence machinery; an equal seed-carried override is deduplicated instead of re-stamped. - **Nesting composes by construction.** A grandchild's stamp folds its parent-the-child's log, which already contains the child's stamped (or self-switched) override — the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter any log, so they can never leak down the chain. @@ -31,7 +31,7 @@ A confined child that hits the wall gets the ordinary denial marker; an escalati ## Consequences -- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers) and the `inheritOverride` contract tests in the two service suites. +- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race and a veto-capable prompt-submit listener) and the `overrideOf`/`stampOverride` contract tests in the two service suites. - The stamped override is the child's own durable record: resume replays it like any switch, and the child may later be switched independently without the driver re-stamping over it (one-shot listener + fold dedup). - Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); a child hard-killed before its first `turn/end` loses the stamp on resume (worthless-resume corner, recorded above); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. - `dsh-subagent-inprocess` now declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 3498b1b019..3ad2bd3dde 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -12,8 +12,8 @@ Status: implemented 共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: -- **创建时读取,首个 `agent/prompt-submit` 时写入。**驱动器在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 -- **只复制覆盖链,且全部走规范写入路径。**`SandboxPolicyService.inheritOverride(parent, child)` 与 `ApprovalService.inheritOverride(parent, child)` 各自折叠父级的完整实时日志(而非 fork 种子),只在父级持有子 agent 尚未折叠出的覆盖项时才通过 `setSandboxMode`/`setApprovalPolicy` 追加,并且从不复制部署默认值:未切换过的父级不盖任何章,因此恢复后的子 agent 继续跟随实时默认值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 +- **只复制覆盖链,且全部走规范写入路径。**`overrideOf(session)` 只是折叠本身——从不包含部署/配置默认值——因此未切换过的父级不盖任何章,恢复后的子 agent 继续跟随实时默认值;`stampOverride(child, value)` 通过 `setSandboxMode`/`setApprovalPolicy` 追加,除非子 agent 已折叠出该值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 - **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 - **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 @@ -31,7 +31,7 @@ Status: implemented ## 后果 -- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言),并由两个服务各自测试套件中的 `inheritOverride` 契约测试钉住。 +- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例,以及一个具备否决能力的 prompt-submit 监听器用例),并由两个服务各自测试套件中的 `overrideOf`/`stampOverride` 契约测试钉住。 - 盖章写入的覆盖项是子 agent 自己的持久记录:恢复时它像任何一次切换一样被回放;子 agent 之后仍可被独立切换,驱动器不会重新盖章覆盖它(一次性监听器加折叠去重)。 - 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);子 agent 在第一个 `turn/end` 前被强制杀死后,恢复时会丢失盖章(恢复无价值的边角场景,上文已记录);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 - `dsh-subagent-inprocess` 现在将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 9226c35529..968ba0b504 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -17,7 +17,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. -- `ctx.sandboxPolicy.inheritOverride(parent, child)` — the delegation-inheritance step: stamps the parent session's effective override (never the deployment default) onto a child session through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver calls it inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +- `ctx.sandboxPolicy.overrideOf(session)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index ba2810bd53..e69a16efbe 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -106,21 +106,32 @@ export class SandboxPolicyService extends Service { } /** - * Stamp the parent's sandbox-mode OVERRIDE onto a child session through the - * canonical write path — the delegation-inheritance step: a child agent runs - * under the policy its delegating parent was switched to, not under the - * (possibly wider) deployment default. Only the override chain is copied: an - * unswitched parent stamps nothing, so the child keeps following the LIVE - * deployment default. A child whose log (e.g. a fork seed) already folds to - * the inherited mode is left untouched. Callers must append inside an open - * child turn — a bare between-turn event is crash-tail garbage on reload. - * @param parent - the delegating session whose effective override is read. - * @param child - the child session the override is appended to. + * A session's sandbox-mode OVERRIDE — the fold alone, never the deployment + * default. The read half of delegation inheritance: the subagent driver + * captures this synchronously at delegation, so a parent switch racing the + * child's asynchronous creation belongs to the parent's future, not to the + * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to fold. + * @returns the last switched mode, or `undefined` for a never-switched session. */ - inheritOverride(parent: Session, child: Session): void { - const inherited = effectiveSandboxMode(parent.events) - if (inherited === undefined || effectiveSandboxMode(child.events) === inherited) return - setSandboxMode(child, inherited) + overrideOf(session: Session): SandboxMode | undefined { + return effectiveSandboxMode(session.events) + } + + /** + * Stamp a captured override onto a child session through the canonical + * write path — the write half of delegation inheritance: a child agent runs + * under the policy its delegating parent was switched to, not under the + * (possibly wider) deployment default. A child whose log (e.g. a fork seed) + * already folds to the mode is left untouched. Callers must append inside + * an open child turn — a bare between-turn event is crash-tail garbage on + * reload. + * @param child - the child session the override is appended to. + * @param mode - the captured {@link overrideOf} value to stamp. + */ + stampOverride(child: Session, mode: SandboxMode): void { + if (effectiveSandboxMode(child.events) === mode) return + setSandboxMode(child, mode) } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 7ced415377..808b5c089b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -143,45 +143,40 @@ describe('the sandbox/mode session kit', () => { }) }) -describe('inheritOverride (parent → child stamping)', () => { +describe('delegation inheritance (overrideOf + stampOverride)', () => { const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode') - it('stamps the parent LAST override onto the child through the canonical write path', async () => { - const ctx = await mounted() + it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) const parent = session('sess-inherit-parent') - const child = session('sess-inherit-child') setSandboxMode(parent, 'workspace-write') setSandboxMode(parent, 'read-only') - ctx.sandboxPolicy.inheritOverride(parent, child) + expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only') + // undefined, NOT the deployment default — a child stamped with the + // default would stop following the LIVE default across resumes. + expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined() + }) + + it('stampOverride appends the captured mode through the canonical write path', async () => { + const ctx = await mounted() + const child = session('sess-inherit-child') + + ctx.sandboxPolicy.stampOverride(child, 'read-only') const stamped = modeEvents(child) expect(stamped).toHaveLength(1) expect(stamped[0]?.data).toEqual({ mode: 'read-only' }) }) - it('appends NOTHING when the parent never switched (the deployment default must stay live)', async () => { - const ctx = await mounted({ mode: 'workspace-write' }) - const parent = session('sess-inherit-default-parent') - const child = session('sess-inherit-default-child') - - ctx.sandboxPolicy.inheritOverride(parent, child) - - // No event — a resumed child keeps following whatever the deployment - // default is THEN, instead of a frozen copy of today's default. - expect(child.events).toHaveLength(0) - }) - - it('skips the append when the child already folds to the inherited mode (fork-seed dedup)', async () => { + it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => { const ctx = await mounted() - const parent = session('sess-inherit-dedup-parent') const child = session('sess-inherit-dedup-child') - setSandboxMode(parent, 'read-only') // A fork seed can already carry the parent's switch; stamping again would // append a redundant event on every delegation. setSandboxMode(child, 'read-only') - ctx.sandboxPolicy.inheritOverride(parent, child) + ctx.sandboxPolicy.stampOverride(child, 'read-only') expect(modeEvents(child)).toHaveLength(1) }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index b41e1acdfa..d3f8d096de 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -16,7 +16,7 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. -The child also inherits the parent's session POLICY overrides: a one-shot `agent/prompt-submit` listener installed during setup stamps the parent's effective `sandbox/mode` and `approval/policy` overrides onto the child through `ctx.sandboxPolicy.inheritOverride` / `ctx.approval.inheritOverride` (both consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request, and its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing; only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each stamp folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and a one-shot PREPENDED `agent/prompt-submit` listener stamps the captured values through `stampOverride` (both services consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request; prepending puts it before veto-capable listeners, so a denying UserPromptSubmit hook cannot close the first turn without the stamp; its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing. Only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each capture folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index b81725ce80..b88eef2548 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -100,6 +100,17 @@ export async function startInProcessRun( subagentDepth: childDepth, } + // Policy inheritance, read half: capture the parent's sandbox/approval + // OVERRIDES synchronously, before the first await — the delegation moment + // is the semantic snapshot point, and a parent switch racing the child's + // asynchronous creation must belong to the parent's future, not the child. + // Both services are consumed opportunistically — without them, delegation + // stays policy-free. + const sandboxPolicy = parent.ctx.get('sandboxPolicy') + const approval = parent.ctx.get('approval') + const inheritedMode = sandboxPolicy?.overrideOf(parent.session) + const inheritedPolicy = approval?.overrideOf(parent.session) + let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { if (request.persona !== undefined) { @@ -109,20 +120,23 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } - // Policy inheritance: stamp the parent's sandbox/approval OVERRIDES onto - // the child once, anchored inside the child's FIRST turn (prompt-submit - // runs after turn/start, before prompt assembly) — a bare between-turn - // append would be crash-tail garbage on reload, and stamping here also - // orders the override after any stale switch a fork seed carried, so the - // ordinary last-event-wins fold resolves it. One-shot: later turns must - // not re-stamp over a switch the child made itself. Both services are - // consumed opportunistically — without them, delegation stays policy-free. - const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => { - disposeInherit() - parent.ctx.get('sandboxPolicy')?.inheritOverride(parent.session, childAgent.session) - parent.ctx.get('approval')?.inheritOverride(parent.session, childAgent.session) - return next() - }) + // Write half: stamp the captured overrides once, anchored inside the + // child's FIRST turn (prompt-submit runs after turn/start, before prompt + // assembly) — a bare between-turn append would be crash-tail garbage on + // reload, and stamping here also orders the override after any stale + // switch a fork seed carried, so the ordinary last-event-wins fold + // resolves it. PREPENDED so a veto-capable listener (a denying + // UserPromptSubmit hook) cannot close the first turn without the stamp — + // the stamp must be durable even for a blocked first prompt. One-shot: + // later turns must not re-stamp over a switch the child made itself. + if (inheritedMode !== undefined || inheritedPolicy !== undefined) { + const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => { + disposeInherit() + if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode) + if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy) + return next() + }, { prepend: true }) + } } const flags = { cancelled: false } diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 6ddcc4f4fb..6e5e78fe9b 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -102,9 +102,11 @@ async function setupBare(script: Script) { * "user switched while idle, model delegates in the very next turn" fork * timing constructible (the post-seed switch lives in the still-open turn). * `fork: true` seeds the child with the caller's completed-turn prefix, - * mirroring the fork provider's slice. + * mirroring the fork provider's slice. `raceSwitch` flips the CALLER's mode + * synchronously after `startInProcessRun`'s synchronous prologue but before + * its creation transaction resolves — the delegation-vs-late-switch race. */ -function registerDelegate(ctx: Context, captured: Agent[]): void { +function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-full-access'): void { ctx.tools.register(defineTool({ name: 'delegate', description: 'delegate a task to an in-process child (test scaffold)', @@ -115,10 +117,15 @@ function registerDelegate(ctx: Context, captured: Agent[]): void { const events = caller.session.events const lastEnd = events.findLast(e => e.type === 'turn/end') const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1) - const run = await startInProcessRun( + const starting = startInProcessRun( { prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal }, args.fork === true && seed.length > 0 ? { seed } : {}, ) + // The caller's turn is still open, so this switch is legal — and it lands + // while the child's creation transaction is pending, strictly before the + // child's first prompt-submit could ever run. + if (raceSwitch !== undefined) setSandboxMode(caller.session, raceSwitch) + const run = await starting captured.push(run.localAgent as Agent) const result = await run.result await run.dispose() @@ -264,6 +271,38 @@ describe('sandbox-mode inheritance against the real fs fence', () => { expect(overrideEvents(child).sandbox).toBe(1) }) + it('inherits the mode AT delegation, not a parent switch racing child creation', async () => { + const script: Script = [] + const captured: Agent[] = [] + const { ctx, parent } = await setupWalled(script) + // The delegate scaffold flips the parent to danger-full-access AFTER + // startInProcessRun's synchronous prologue, while the child's creation + // transaction is still pending — the value at delegation is read-only. + registerDelegate(ctx, captured, 'danger-full-access') + const blocked = join(workspace, 'race-blocked.txt') + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + toolCallResponse('d-race', 'delegate', { fork: false }), + toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), + textResponse('race child done'), + textResponse('turn two done'), + ) + parent.send([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'delegate' }]) + await parent.whenIdle() + + const child = captured[0] as Agent + // The child runs under the snapshot taken at delegation — the racing + // wider switch belongs to the parent's own future, not to the child. + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) + expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') + }) + it('a GRANDCHILD inherits through the chain (child delegates again)', async () => { const script: Script = [] const captured: Agent[] = [] @@ -297,6 +336,43 @@ describe('sandbox-mode inheritance against the real fs fence', () => { }) }) +describe('inheritance survives prompt vetoes', () => { + it('stamps the child even when an earlier-registered prompt-submit listener vetoes without next()', async () => { + const script: Script = [] + const { ctx, parent } = await setupWalled(script) + // A veto-capable listener registered BEFORE the child exists — the + // Claude/Codex UserPromptSubmit hook shape: it blocks the child's prompt + // and never delegates. Inheritance must still run for the first turn. + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => { + if (agent.session.header.parentSession !== undefined) { + return Promise.resolve({ kind: 'block' as const, reason: 'vetoed by test hook' }) + } + return next() + }) + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + // No child model entries: the blocked prompt closes a zero-step turn. + ) + parent.send([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + const child = run.localAgent as Agent + + // The veto closed the first turn promptless, but the stamp is inside that + // turn regardless — a later resume must not fall back to the deployment + // default just because the first prompt was blocked. + expect(overrideEvents(child)).toEqual({ sandbox: 1, approval: 0 }) + expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') + + await run.dispose() + }) +}) + describe('inheritance guards (must hold before AND after the fix)', () => { it('a child of an unswitched parent runs under the live deployment default, with ZERO stamped events', async () => { const script: Script = [] diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 0ac341b6b4..27a4a1e622 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.inheritOverride(parent, child)` stamps a parent session's override (never the configured default) onto a child session through that write path — the in-process subagent driver calls it inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index be059577e5..06f94a5ddb 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -327,21 +327,31 @@ export class ApprovalService extends Service { } /** - * Stamp the parent's approval-policy OVERRIDE onto a child session through - * the canonical write path — the delegation-inheritance step: a `'never'` + * A session's approval-policy OVERRIDE — the fold alone, never the + * configured default. The read half of delegation inheritance: the subagent + * driver captures this synchronously at delegation, so a parent switch + * racing the child's asynchronous creation belongs to the parent's future, + * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to fold. + * @returns the last switched policy, or `undefined` for a never-switched session. + */ + overrideOf(session: Session): ApprovalPolicy | undefined { + return effectiveApprovalPolicy(session.events) + } + + /** + * Stamp a captured override onto a child session through the canonical + * write path — the write half of delegation inheritance: a `'never'` * (headless/CI) parent must not mint children that fall back to a prompting - * default. Only the override chain is copied: an unswitched parent stamps - * nothing, so the child keeps following the LIVE configured default. A - * child whose log (e.g. a fork seed) already folds to the inherited policy + * default. A child whose log (e.g. a fork seed) already folds to the policy * is left untouched. Callers must append inside an open child turn — a bare * between-turn event is crash-tail garbage on reload. - * @param parent - the delegating session whose effective override is read. * @param child - the child session the override is appended to. + * @param policy - the captured {@link overrideOf} value to stamp. */ - inheritOverride(parent: Session, child: Session): void { - const inherited = effectiveApprovalPolicy(parent.events) - if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return - setApprovalPolicy(child, inherited) + stampOverride(child: Session, policy: ApprovalPolicy): void { + if (effectiveApprovalPolicy(child.events) === policy) return + setApprovalPolicy(child, policy) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 83661a5d2b..9b1664c27c 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -577,44 +577,39 @@ describe('approval policy (the approval/policy fold)', () => { }) }) -describe('inheritOverride (parent → child stamping)', () => { +describe('delegation inheritance (overrideOf + stampOverride)', () => { const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy') function bareSession(id: string): Session { return new Session(SessionId(id)) } - it('stamps the parent LAST override onto the child through the canonical write path', async () => { + it('overrideOf folds to the LAST override and never falls back to the configured default', async () => { const ctx = await mounted() const parent = bareSession('sess-appr-inherit-parent') - const child = bareSession('sess-appr-inherit-child') setApprovalPolicy(parent, 'never') - ctx.approval.inheritOverride(parent, child) + expect(ctx.approval.overrideOf(parent)).toBe('never') + expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() + }) + + it('stampOverride appends the captured policy through the canonical write path', async () => { + const ctx = await mounted() + const child = bareSession('sess-appr-inherit-child') + + ctx.approval.stampOverride(child, 'never') const stamped = policyEvents(child) expect(stamped).toHaveLength(1) expect(stamped[0]?.data).toEqual({ policy: 'never' }) }) - it('appends NOTHING when the parent never switched (the configured default must stay live)', async () => { + it('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => { const ctx = await mounted() - const parent = bareSession('sess-appr-default-parent') - const child = bareSession('sess-appr-default-child') - - ctx.approval.inheritOverride(parent, child) - - expect(child.events).toHaveLength(0) - }) - - it('skips the append when the child already folds to the inherited policy (fork-seed dedup)', async () => { - const ctx = await mounted() - const parent = bareSession('sess-appr-dedup-parent') const child = bareSession('sess-appr-dedup-child') - setApprovalPolicy(parent, 'never') setApprovalPolicy(child, 'never') - ctx.approval.inheritOverride(parent, child) + ctx.approval.stampOverride(child, 'never') expect(policyEvents(child)).toHaveLength(1) }) From 827c6f50c19583431ff8240772ebfa029e30bc86 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 11:17:32 +0800 Subject: [PATCH 03/61] subagent: refresh snapshot sidecars and zh notes after master merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge reconciliation: master's plan-mode command wording and tool-schema changes land in the recorded scenario's keyless sidecars (refresh, not re-record — the model transcript is untouched); the abort-handoff fake agent stubs ctx.get for the driver's new synchronous inheritance capture; the two zh Agent Notes gain the sentences their English sides added on this branch, re-recorded in the pairing manifests. --- .../feature/2026-07-06-approval-seam.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-06-approval-seam.zh.md | 2 +- .../implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- .../subagent-sandbox-inheritance/stdout.expected.jsonl | 2 +- .../tool-schemas.expected.json | 10 ++++++++-- .../tests/subagent-inprocess.spec.ts | 3 +++ 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index ec785c65c4..1b31d76e29 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.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-06-approval-seam.md: e28587e0185530164f300562f7dd5e57255ac515 -2026-07-06-approval-seam.zh.md: 94ffb330cd36ae88975e8ae9d579b3b4606a5c25 +2026-07-06-approval-seam.md: 2268181bd3c5655e60b62f23b63978072d663346 +2026-07-06-approval-seam.zh.md: 67fb4f7adff359424c46b03b68d6ad7c53eeded2 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 94ffb330cd..67fb4f7adf 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `s - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。但会话策略确实会跨过这条边界:`'never'` 父级的子 agent 通过盖章写入的覆盖项继承 `'never'`([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md)),因此它们一开始就被告知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。 - **热重载或 UI 插件在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 287deaff20..efc3e062ca 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md: c1307a7201ed1bc331a86d4ffee002d69fd1d5e5 -2026-07-06-sandbox.zh.md: 4e3d4951f71a602bc78551c90dda04317d0f8284 +2026-07-06-sandbox.md: 0ecb8e853cb4e221b02c49999be7e1c2943ff5aa +2026-07-06-sandbox.zh.md: ec3cf740a218afa72f6b986d7322aad5fee8ffae diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 4e3d4951f7..ec3cf740a2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -92,7 +92,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`)都是构造性的自然结果,且不存在任何外部配置存储。 +默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`)都是构造性的自然结果,且不存在任何外部配置存储。隔离并不使委派成为逃生通道:进程内 subagent 驱动器会在子 agent 的首个轮次内,把发起委派的父级的有效覆盖盖章写入每个子会话,因此收紧后的父级会约束 spawn 子 agent、fork 子 agent 与孙代 agent([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl index d95775322d..31cc1fcdf4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.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,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"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":"read-only","name":"read-only","description":"Read all files; every mutation is denied without a per-call approval."},{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"read-only","options":[{"value":"read-only","name":"read-only","description":"Read all files; every mutation is denied without a per-call approval."},{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the subagent tool exactly","updatedAt":"{{updatedAt}}"}}} {"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/subagent-sandbox-inheritance/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json index 61d317e2f8..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", + "additionalProperties": true, "properties": { "label": { "type": "string", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "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?, provider?, 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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.", + "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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,8 @@ }, "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\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index ce91480ea8..46ea9e2e30 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -203,6 +203,9 @@ describe('startInProcessRun', () => { options: parent.options, session: parent.session, ctx: { + // The driver's synchronous inheritance capture probes both policy + // services opportunistically; this stub composes neither. + get: () => undefined, agents: { create: async (options: Parameters[0]) => { const handle = await ctx.agents.create(options) From ea2241c2a92279b8f3801e881470074af422d3b4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 16:27:35 +0800 Subject: [PATCH 04/61] docs(notes): replace the retired ACP-anchoring analogy with the injection-turn fact The automation-only ACP refactor (#586) removed the bridge's pendingSwitches idle-preset anchoring the inheritance note cited as precedent. Replace the dangling analogy with the load-bearing fact a reviewer actually needs: an injection-triggered first turn (a SessionStart hook racing the prompt) dispatches no prompt-submit and no model request, so the one-shot stamp listener still lands before the child's first model request. --- .../feature/2026-07-25-subagent-policy-inheritance.i18n.yaml | 4 ++-- .../feature/2026-07-25-subagent-policy-inheritance.md | 2 +- .../feature/2026-07-25-subagent-policy-inheritance.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 14cc3d747c..163bbe57c4 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: 42196f9b23b9bf31a1f441e9db900493f08e8094 -2026-07-25-subagent-policy-inheritance.zh.md: 3ad2bd3dde913850c0b7726a8b650d4f7f75ceda +2026-07-25-subagent-policy-inheritance.md: 000e59dc9a9208e57db5dd44d34bd6ce540801da +2026-07-25-subagent-policy-inheritance.zh.md: 90738b4c298eea879a0093bf919139c8c70f00ef diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 42196f9b23..000e59dc9a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -12,7 +12,7 @@ Session policy overrides are per-session log folds: the effective sandbox mode i The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) snapshots the parent's policy overrides at delegation and stamps them onto the child as ordinary log events inside the child's FIRST turn: -- **Capture synchronously at delegation, stamp at first `agent/prompt-submit`.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window, PREPENDED so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the first turn without the stamp. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). This is the same anchoring the ACP bridge uses for idle preset switches. +- **Capture synchronously at delegation, stamp at first `agent/prompt-submit`.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window, PREPENDED so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the first turn without the stamp. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). An injection-triggered first turn (a SessionStart hook racing the prompt) cannot starve the listener: injection turns dispatch no prompt-submit and no model request, so the stamp still lands before the child's first model request. - **Only the override chain is copied, through the canonical write paths.** `overrideOf(session)` is the fold alone — never the deployment/configured default — so an unswitched parent stamps nothing and a resumed child keeps following the LIVE default; `stampOverride(child, value)` appends via `setSandboxMode`/`setApprovalPolicy` unless the child already folds to the value. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. - **Fork stale-seed precedence falls out of log order.** The stamped event lands after any switch the seed carried, so the existing last-event-wins fold resolves the child's mode with no new precedence machinery; an equal seed-carried override is deduplicated instead of re-stamped. - **Nesting composes by construction.** A grandchild's stamp folds its parent-the-child's log, which already contains the child's stamped (or self-switched) override — the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter any log, so they can never leak down the chain. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 3ad2bd3dde..90738b4c29 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -12,7 +12,7 @@ Status: implemented 共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: -- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。ACP(Agent Client Protocol)桥接器处理空闲时预设切换所用的正是同一种锚定方式。 +- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。由注入触发的第一个轮次(SessionStart 钩子与提示词赛跑)不会饿死该监听器:注入轮次既不派发 prompt-submit 也不发起模型请求,因此盖章仍会落在子 agent 的第一次模型请求之前。 - **只复制覆盖链,且全部走规范写入路径。**`overrideOf(session)` 只是折叠本身——从不包含部署/配置默认值——因此未切换过的父级不盖任何章,恢复后的子 agent 继续跟随实时默认值;`stampOverride(child, value)` 通过 `setSandboxMode`/`setApprovalPolicy` 追加,除非子 agent 已折叠出该值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 - **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 - **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 From 166628c0b3204b64824203e49100edb696cc1c21 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 16:57:07 +0800 Subject: [PATCH 05/61] docs: regenerate the module graph for the subagent-inprocess policy peers --- docs/module-graph.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0ccb4ffcc0..b2c4e45fdd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -695,10 +695,12 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm + pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools + pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -950,7 +952,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | From c53e9c90dba30f638fd7fe14c0b0993fdbd2c50f Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 18:16:45 +0800 Subject: [PATCH 06/61] subagent: carry inherited policy overrides in the child session header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (ds-review-bot critical #2 on #623): the first-turn event stamp had a durability hole no turn anchoring can close — an idle SessionStart- style injection persists a complete one-shot turn before any prompt turn opens, so a crash in that window left a resumable-looking child with no inherited policy, falling back to a possibly wider deployment default. The captured overrides now ride the child's creation meta into its immutable SessionHeader (sandboxMode/approvalPolicy, neutral strings at the session boundary — the delegationDepth precedent), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it. overrideOf(session) on both policy services resolves fold(events past header.seedLength) ?? header baseline, validating against the closed vocabulary on read; stampOverride and the prompt-submit listener machinery are deleted. The header field rides both persistence backends (JSONL header line; SQLite sessions columns, SCHEMA_VERSION 11 — pre-release, no migration). pty-local reads through overrideOf so PTY spawns see the baseline too. Red-first: header-durability-before-any-turn test (the injection crash window shape), baseline/seed-boundary/closed-vocabulary contract tests in both service suites; the real-wall suite (race, veto, fork stale-seed, grandchild) re-anchored on header assertions and green. The Agent Note's Alternatives now records the superseded event-stamping iteration with the review evidence; bilingual docs updated. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 24 +++---- ...26-07-25-subagent-policy-inheritance.zh.md | 24 +++---- docs/cordis-catalog/services.md | 69 ++++++++----------- .../persistence.i18n.yaml | 4 +- docs/core-data-structures/persistence.md | 22 +++++- docs/core-data-structures/persistence.zh.md | 22 +++++- docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 28 ++++---- packages/acp/acp/tests/approval.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 18 ++--- packages/core/agent/src/index.ts | 8 ++- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 +- packages/core/session/README.zh.md | 6 +- packages/core/session/src/index.ts | 10 +++ packages/core/session/src/types.ts | 20 ++++++ packages/core/tools/tests/tools.spec.ts | 2 +- packages/pty/pty-local/src/index.ts | 8 ++- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 49 +++++++------ .../sandbox-policy/tests/policy.spec.ts | 55 +++++++++------ .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 10 +++ .../session-persistence-sqlite/src/index.ts | 10 ++- .../session-persistence-sqlite/src/schema.ts | 12 +++- .../tests/sqlite.spec.ts | 4 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 38 ++++------ .../tests/inheritance.spec.ts | 57 +++++++++++---- packages/ui/user-approval/README.i18n.yaml | 4 +- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/README.zh.md | 2 +- packages/ui/user-approval/src/index.ts | 46 ++++++------- .../ui/user-approval/tests/approval.spec.ts | 55 ++++++++++----- 41 files changed, 387 insertions(+), 264 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 163bbe57c4..1de32450b5 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: 000e59dc9a9208e57db5dd44d34bd6ce540801da -2026-07-25-subagent-policy-inheritance.zh.md: 90738b4c298eea879a0093bf919139c8c70f00ef +2026-07-25-subagent-policy-inheritance.md: a15a20aeb8324f00fcb9ca85aac5d5e2352dfac3 +2026-07-25-subagent-policy-inheritance.zh.md: 2ac844fdd9bbb1b4a57e76a96a5cd5270c23d2aa diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 000e59dc9a..a15a20aeb8 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -10,28 +10,28 @@ Session policy overrides are per-session log folds: the effective sandbox mode i ## Decision -The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) snapshots the parent's policy overrides at delegation and stamps them onto the child as ordinary log events inside the child's FIRST turn: +The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the `delegationDepth` precedent: -- **Capture synchronously at delegation, stamp at first `agent/prompt-submit`.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and installs a one-shot child-scoped `agent/prompt-submit` listener during the creation transaction's setup window, PREPENDED so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the first turn without the stamp. Prompt-submit runs after `turn/start` and before prompt assembly, so the stamped events are turn-enclosed (durable — a bare between-turn event is crash-tail garbage on reload) and visible to the child's very first request (an inherited `'never'` reaches the child's first system prompt). An injection-triggered first turn (a SessionStart hook racing the prompt) cannot starve the listener: injection turns dispatch no prompt-submit and no model request, so the stamp still lands before the child's first model request. -- **Only the override chain is copied, through the canonical write paths.** `overrideOf(session)` is the fold alone — never the deployment/configured default — so an unswitched parent stamps nothing and a resumed child keeps following the LIVE default; `stampOverride(child, value)` appends via `setSandboxMode`/`setApprovalPolicy` unless the child already folds to the value. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. -- **Fork stale-seed precedence falls out of log order.** The stamped event lands after any switch the seed carried, so the existing last-event-wins fold resolves the child's mode with no new precedence machinery; an equal seed-carried override is deduplicated instead of re-stamped. -- **Nesting composes by construction.** A grandchild's stamp folds its parent-the-child's log, which already contains the child's stamped (or self-switched) override — the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter any log, so they can never leak down the chain. +- **Capture synchronously at delegation, persist in the creation-time header.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. +- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — on `SandboxPolicyService` and `ApprovalService` — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary on every read (durable-boundary validation) and throws on foreign values. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. +- **Fork stale-seed precedence falls out of the seed boundary.** A fork seed may carry the parent's OLD switch events; `overrideOf` folds only events past `header.seedLength`, so seed-carried history is subsumed by the delegation-time baseline while a switch the child makes ITSELF still outranks it. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. +- **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, so they can never leak down the chain. ### What a blocked child experiences -A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by an editor — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation. +A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by a controller that can answer — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation. ## Alternatives considered -- **A `sandboxMode`/`approvalPolicy` baseline in `SessionHeader` meta (the `delegationDepth` precedent)** — rejected: it survives the one corner the event approach loses (a child hard-killed before its first `turn/end` and then resumed loses the stamp), but that child has completed nothing and has no resume value, while the header field costs a session-format extension, a durable-boundary validation path, seed-slicing precedence logic in every fold consumer (`resolve()`, pty-local, permission display), and a second home for policy state. The event approach changes no fold, no format, and no consumer. +- **Stamping the inherited override as `sandbox/mode`/`approval/policy` events inside the child's first turn (the shipped first iteration)** — superseded: it kept the log-as-store idiom with zero format changes, but review surfaced a durability hole the turn-enclosure contract cannot patch — an idle SessionStart-style injection persists a complete one-shot turn BEFORE any prompt turn opens, so a crash in that window leaves a resumable-looking child with no inherited policy, and no event anchor exists earlier (creation-time appends are crash-tail garbage, injection turns dispatch no waterfall, `session/event` listeners cannot re-append). The header baseline closes every timing window at once and deletes the listener/prepend/dedup machinery the event approach needed. - **Stamping at child creation (outside any turn)** — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. - **Forcing `approvalPolicy: 'never'` onto every in-process child** — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons. -- **Routing a child's approval asks to the root session's editor** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived. +- **Routing a child's approval asks to the root session's controller** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived. ## Consequences -- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth. Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race and a veto-capable prompt-submit listener) and the `overrideOf`/`stampOverride` contract tests in the two service suites. -- The stamped override is the child's own durable record: resume replays it like any switch, and the child may later be switched independently without the driver re-stamping over it (one-shot listener + fold dedup). -- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); a child hard-killed before its first `turn/end` loses the stamp on resume (worthless-resume corner, recorded above); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. -- `dsh-subagent-inprocess` now declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. +- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). +- The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. +- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. +- `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 90738b4c29..2ac844fdd9 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -10,28 +10,28 @@ Status: implemented ## 决策 -共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时快照父级的策略覆盖项,并在子 agent 的第一个轮次内把它们作为普通日志事件盖章写入子会话: +共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: -- **委派时同步捕获,首个 `agent/prompt-submit` 时盖章。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并在创建事务的 setup 窗口内安装一个一次性的、限定子 agent 作用域的 `agent/prompt-submit` 监听器,且采用前置安装,使得具备否决能力的监听器(会作出拒绝的 UserPromptSubmit 钩子)无法在未盖章的情况下结束第一个轮次。prompt-submit 阶段在 `turn/start` 之后、提示词组装之前运行,因此盖章事件被包围在轮次内(具备持久性:轮次之间的裸事件在重新加载时只是崩溃残留的尾部垃圾),并且对子 agent 的第一次请求可见(继承来的 `'never'` 能进入子 agent 的第一份系统提示词)。由注入触发的第一个轮次(SessionStart 钩子与提示词赛跑)不会饿死该监听器:注入轮次既不派发 prompt-submit 也不发起模型请求,因此盖章仍会落在子 agent 的第一次模型请求之前。 -- **只复制覆盖链,且全部走规范写入路径。**`overrideOf(session)` 只是折叠本身——从不包含部署/配置默认值——因此未切换过的父级不盖任何章,恢复后的子 agent 继续跟随实时默认值;`stampOverride(child, value)` 通过 `setSandboxMode`/`setApprovalPolicy` 追加,除非子 agent 已折叠出该值。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由日志顺序自然得出。**盖章事件落在种子携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析出子 agent 的模式,无需新增优先级机制;种子已携带相同覆盖项时会去重,而不会重复盖章。 -- **嵌套按构造即可组合。**孙代 agent 盖章时折叠的是其父级(即上一层的子 agent)的日志,而该日志已经包含这个子 agent 被盖章(或自行切换)的覆盖项:这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志,因此永远不可能沿链向下泄漏。 +- **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 +- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——位于 `SandboxPolicyService` 与 `ApprovalService` 上——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都按自己的封闭词汇校验(持久边界校验),遇到词汇之外的值即抛出异常。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **fork 陈旧种子的优先级由种子边界自然得出。**fork 种子可能携带父级旧的切换事件;`overrideOf` 只折叠 `header.seedLength` 之后的事件,因此种子携带的历史已被委派时的基线所涵盖,而子 agent 自己做出的切换仍然优先于基线。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 +- **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 ### 被拦住的子 agent 会经历什么 -受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由编辑器持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。 +受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由一个能够应答的控制方持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。 ## 考虑过的替代方案 -- **在 `SessionHeader` 的 meta 中放 `sandboxMode`/`approvalPolicy` 基线(沿用 `delegationDepth` 先例)**:不予采纳。它确实能扛住事件方案唯一丢失的边角场景(子 agent 在第一个 `turn/end` 前被强制杀死、随后又被恢复时会丢失盖章),但那样的子 agent 尚未完成任何工作,恢复毫无价值;而该会话头字段的代价是一次会话格式扩展、一条持久边界上的校验路径、每个折叠消费方(`resolve()`、pty-local、权限展示)里的种子切片优先级逻辑,以及策略状态的第二个存放处。事件方案不改动任何折叠、任何格式、任何消费方。 +- **在子 agent 的第一个轮次内,把继承的覆盖项作为 `sandbox/mode`/`approval/policy` 事件盖章写入(已合入的第一版实现)**:已被取代。它保住了「日志即存储」的惯用法,零格式变更,但评审发现了一个轮次封闭契约无法修补的持久性漏洞:空闲时的 SessionStart 式注入会在任何提示词轮次开启之前就持久化一个完整的一次性轮次,在该窗口内崩溃会留下一个看似可恢复、却没有任何继承策略的子 agent,而且不存在更早的事件锚点(创建时的追加只是崩溃残留的尾部垃圾,注入轮次不派发任何 waterfall,`session/event` 监听器也无法重入追加)。会话头基线一举关闭所有时序窗口,并删除了事件方案所需的监听器/前置安装/去重机制。 - **在子 agent 创建时(任何轮次之外)盖章**:不予采纳。持久化契约在轮次边界提交,因此轮次开始前的裸事件在重新加载时会被当作撕裂尾部截断;会话不变量测试套件会直接判这种追加失败。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 - **给每个进程内子 agent 强制设置 `approvalPolicy: 'never'`**:不予采纳。这在今天是事实(没有应答器认领它们),但它会静默排除未来能够服务子 agent 的应答器,并搅浑继承语义;只继承父级的覆盖项既保住 fail-closed 结果,又让每次请求的拒绝原因保持诚实。 -- **把子 agent 的审批请求路由给根会话的编辑器**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。 +- **把子 agent 的审批请求路由给根会话的控制方**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。 ## 后果 -- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例,以及一个具备否决能力的 prompt-submit 监听器用例),并由两个服务各自测试套件中的 `overrideOf`/`stampOverride` 契约测试钉住。 -- 盖章写入的覆盖项是子 agent 自己的持久记录:恢复时它像任何一次切换一样被回放;子 agent 之后仍可被独立切换,驱动器不会重新盖章覆盖它(一次性监听器加折叠去重)。 -- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);子 agent 在第一个 `turn/end` 前被强制杀死后,恢复时会丢失盖章(恢复无价值的边角场景,上文已记录);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 -- `dsh-subagent-inprocess` 现在将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。 +- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 +- 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 +- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 +- `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 259c79ead2..2a137c2f70 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:225`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:227`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -244,27 +244,21 @@ Approval service that applies session policy before answerers and logs every ask async request(req: ApprovalRequest): Promise /** - * A session's approval-policy OVERRIDE — the fold alone, never the - * configured default. The read half of delegation inheritance: the subagent - * driver captures this synchronously at delegation, so a parent switch - * racing the child's asynchronous creation belongs to the parent's future, - * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - * @param session - the session whose override chain to fold. - * @returns the last switched policy, or `undefined` for a never-switched session. + * A session's approval-policy OVERRIDE — the override chain alone, never + * the configured default: the fold of the session's OWN switches (events + * past the seed boundary — a fork seed's stale parent switch is subsumed by + * the baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so a `'never'` (headless/CI) parent cannot + * mint children that fall back to a prompting default, at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the configured default. + * @throws when the durable header baseline is outside the closed policy + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined - -/** - * Stamp a captured override onto a child session through the canonical - * write path — the write half of delegation inheritance: a `'never'` - * (headless/CI) parent must not mint children that fall back to a prompting - * default. A child whose log (e.g. a fork seed) already folds to the policy - * is left untouched. Callers must append inside an open child turn — a bare - * between-turn event is crash-tail garbage on reload. - * @param child - the child session the override is appended to. - * @param policy - the captured {@link overrideOf} value to stamp. - */ -stampOverride(child: Session, policy: ApprovalPolicy): void ``` Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) @@ -974,28 +968,21 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy /** - * A session's sandbox-mode OVERRIDE — the fold alone, never the deployment - * default. The read half of delegation inheritance: the subagent driver - * captures this synchronously at delegation, so a parent switch racing the - * child's asynchronous creation belongs to the parent's future, not to the - * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - * @param session - the session whose override chain to fold. - * @returns the last switched mode, or `undefined` for a never-switched session. + * A session's sandbox-mode OVERRIDE — the override chain alone, never the + * deployment default: the fold of the session's OWN switches (events past + * the seed boundary — a fork seed's stale parent switch is subsumed by the + * baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so the chain collapses one level per + * delegation and a tightened parent binds children at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the deployment default. + * @throws when the durable header baseline is outside the closed mode + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined - -/** - * Stamp a captured override onto a child session through the canonical - * write path — the write half of delegation inheritance: a child agent runs - * under the policy its delegating parent was switched to, not under the - * (possibly wider) deployment default. A child whose log (e.g. a fork seed) - * already folds to the mode is left untouched. Callers must append inside - * an open child turn — a bare between-turn event is crash-tail garbage on - * reload. - * @param child - the child session the override is appended to. - * @param mode - the captured {@link overrideOf} value to stamp. - */ -stampOverride(child: Session, mode: SandboxMode): void ``` Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) @@ -1389,7 +1376,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ebb8bfb6f3..3a0dd632f7 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 -persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e -persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd +persistence.md: 0c8d067fc28ee9075354bf32e511e72d66ef530d +persistence.zh.md: fcb07486a401283ff52e64a4d8ed2f25e2218187 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4ec967873e..0c8d067fc2 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -72,12 +72,30 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, the inherited `sandboxMode`/`approvalPolicy` delegation baselines, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -98,6 +116,8 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3030ff2fe9..fcb07486a4 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -72,12 +72,30 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`、继承的 `sandboxMode`/`approvalPolicy` 委派基线,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -98,6 +116,8 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8b8a73986e..b901425b8b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -17,7 +17,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cfa787d4e3..a97b28948d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:418`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `compact/*` @@ -329,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `request/*` @@ -343,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -399,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -503,7 +503,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) ### `turn/*` @@ -521,7 +521,7 @@ Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -537,7 +537,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) ### `user/*` @@ -556,4 +556,4 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 01bcd83249..bacb7b4842 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -61,7 +61,7 @@ describe('ACP machine permission policy', () => { harness = await makeBridgeHarness() const request = await ownedRequest() const foreign = { - session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], header: { version: 0, id: request.agent.session.id, createdAt: 0 }, append: () => ({}) }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1a8e21a957..a586702cc7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,11 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the fold alone, never the\n * configured default. The read half of delegation inheritance: the subagent\n * driver captures this synchronously at delegation, so a parent switch\n * racing the child\'s asynchronous creation belongs to the parent\'s future,\n * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched policy, or `undefined` for a never-switched session.\n */', - }, - { - signature: 'stampOverride(child: Session, policy: ApprovalPolicy): void', - jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a `\'never\'`\n * (headless/CI) parent must not mint children that fall back to a prompting\n * default. A child whose log (e.g. a fork seed) already folds to the policy\n * is left untouched. Callers must append inside an open child turn — a bare\n * between-turn event is crash-tail garbage on reload.\n * @param child - the child session the override is appended to.\n * @param policy - the captured {@link overrideOf} value to stamp.\n */', + jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the override chain alone, never\n * the configured default: the fold of the session\'s OWN switches (events\n * past the seed boundary — a fork seed\'s stale parent switch is subsumed by\n * the baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so a `\'never\'` (headless/CI) parent cannot\n * mint children that fall back to a prompting default, at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n * @throws when the durable header baseline is outside the closed policy\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', }, ], }, @@ -496,11 +492,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', - jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the fold alone, never the deployment\n * default. The read half of delegation inheritance: the subagent driver\n * captures this synchronously at delegation, so a parent switch racing the\n * child\'s asynchronous creation belongs to the parent\'s future, not to the\n * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to fold.\n * @returns the last switched mode, or `undefined` for a never-switched session.\n */', - }, - { - signature: 'stampOverride(child: Session, mode: SandboxMode): void', - jsDoc: '/**\n * Stamp a captured override onto a child session through the canonical\n * write path — the write half of delegation inheritance: a child agent runs\n * under the policy its delegating parent was switched to, not under the\n * (possibly wider) deployment default. A child whose log (e.g. a fork seed)\n * already folds to the mode is left untouched. Callers must append inside\n * an open child turn — a bare between-turn event is crash-tail garbage on\n * reload.\n * @param child - the child session the override is appended to.\n * @param mode - the captured {@link overrideOf} value to stamp.\n */', + jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the override chain alone, never the\n * deployment default: the fold of the session\'s OWN switches (events past\n * the seed boundary — a fork seed\'s stale parent switch is subsumed by the\n * baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so the chain collapses one level per\n * delegation and a tightened parent binds children at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n * @throws when the durable header baseline is outside the closed mode\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', }, ], }, @@ -1514,7 +1506,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateGoalRequest', @@ -1522,7 +1514,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n}', }, { name: 'DiffCallView', @@ -2002,7 +1994,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b939bd6e34..fe80dcec08 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -48,9 +48,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` - * recursion budget. Mirrors the - * `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of + * fork lineage, the `seedLength` seed boundary, the `delegationDepth` + * recursion budget, and the inherited `sandboxMode`/`approvalPolicy` + * delegation baselines. Mirrors the corresponding fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, @@ -62,6 +62,8 @@ export interface CreateAgentOptions { readonly parentSession?: SessionId readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } /** * Seed events to reconstruct the child session's log from (the fork lineage diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 2ce7add3c2..53358283c8 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c -README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb +README.md: d1f13669c224ea9fe70a08e6ecacd15118bfb632 +README.zh.md: ef9dd562a2c849f933ed8dafbfa65c92a3e6bcad diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 18d6d385ff..d1f13669c2 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome. @@ -44,7 +44,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 93ea574eb0..ef9dd562a2 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,7 +12,7 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。 @@ -44,7 +44,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -87,7 +87,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..82354bf825 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -151,6 +151,14 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } + // Neutral strings only: the owning policy packages validate the values + // against their closed vocabularies on read (durable boundary). + if (record.sandboxMode !== undefined && typeof record.sandboxMode !== 'string') { + throw new Error('session header sandboxMode must be a string') + } + if (record.approvalPolicy !== undefined && typeof record.approvalPolicy !== 'string') { + throw new Error('session header approvalPolicy must be a string') + } return deepFreeze(record as unknown as SessionHeader) } @@ -680,6 +688,8 @@ export class SessionStore extends Service { ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, + ...meta?.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, + ...meta?.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, } return new Session(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index a01f1a6a79..f5e5d48c77 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -53,6 +53,24 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * The sandbox-mode override inherited from the delegating parent at + * creation (the delegation-inheritance baseline). A neutral string here: + * the policy owner (`dsh-sandbox-policy`) validates it against its closed + * vocabulary on every read, this being a durable boundary. Absent for + * top-level sessions and for children of unswitched parents, which keep + * following the LIVE deployment default. Header-carried (the + * `delegationDepth` precedent) so the baseline is durable from the creation + * moment — no first-turn event survives every crash window, because an + * idle injection can persist a complete turn before any prompt turn opens. + */ + readonly sandboxMode?: string + /** + * The approval-policy override inherited from the delegating parent at + * creation. Same contract as {@link SessionHeader.sandboxMode}; validated + * by `dsh-user-approval` on read. + */ + readonly approvalPolicy?: string } /** @@ -73,6 +91,8 @@ export interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number + readonly sandboxMode?: string + readonly approvalPolicy?: string } } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 50c8654b9e..ce481bea2d 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -661,7 +661,7 @@ describe('ToolRegistry', () => { */ function fakeAgent(): Agent { return { - session: { events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) }, } as unknown as Agent } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index b466ecfc46..85d3938eb8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -9,10 +9,12 @@ import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +// Type-only: the `ctx.sandboxPolicy` Context merge and the `sandbox/mode` +// SessionEventMap merge; the service itself arrives via `inject`. +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' @@ -47,7 +49,7 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode + const currentMode = state.sandboxPolicy.overrideOf(session) ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, @@ -76,7 +78,7 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] { const argv = [config.shellPath, ...config.shellArgs] - const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode + const mode: SandboxMode = ctx.sandboxPolicy.overrideOf(spec.owner.session) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv return ctx.sandbox.confine(argv, { mode: mode, diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 67117d4284..778b213f3d 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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: 3e6d8082766d718b0cfcb94369f48a283713fcd2 -README.zh.md: 2f84bd4971ddbe0f15e78ef7f5f069350ff43f1f +README.md: 733a14141a8a67728b026c7a19d18266b61cbf6a +README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 3e6d808276..733a14141a 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -19,7 +19,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. -- `ctx.sandboxPolicy.overrideOf(session)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +- `ctx.sandboxPolicy.overrideOf(session)` — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2f84bd4971..916baf00b6 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -19,7 +19,7 @@ - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 - `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `ctx.sandboxPolicy.overrideOf(session)`/`ctx.sandboxPolicy.stampOverride(child, mode)`:委派继承的两半:仅折叠本身(绝不包含部署默认值),以及通过 `setSandboxMode` 写入捕获的覆盖项,子 agent 已折叠出该值时跳过。进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使发起委派的父级收紧后的模式约束其子 agent(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index e69a16efbe..46124f2373 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,7 +19,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import { effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +import { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' @@ -100,38 +100,35 @@ export class SandboxPolicyService extends Service { resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request return { - mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode, + mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } /** - * A session's sandbox-mode OVERRIDE — the fold alone, never the deployment - * default. The read half of delegation inheritance: the subagent driver - * captures this synchronously at delegation, so a parent switch racing the - * child's asynchronous creation belongs to the parent's future, not to the - * child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - * @param session - the session whose override chain to fold. - * @returns the last switched mode, or `undefined` for a never-switched session. + * A session's sandbox-mode OVERRIDE — the override chain alone, never the + * deployment default: the fold of the session's OWN switches (events past + * the seed boundary — a fork seed's stale parent switch is subsumed by the + * baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so the chain collapses one level per + * delegation and a tightened parent binds children at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the deployment default. + * @throws when the durable header baseline is outside the closed mode + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined { - return effectiveSandboxMode(session.events) - } - - /** - * Stamp a captured override onto a child session through the canonical - * write path — the write half of delegation inheritance: a child agent runs - * under the policy its delegating parent was switched to, not under the - * (possibly wider) deployment default. A child whose log (e.g. a fork seed) - * already folds to the mode is left untouched. Callers must append inside - * an open child turn — a bare between-turn event is crash-tail garbage on - * reload. - * @param child - the child session the override is appended to. - * @param mode - the captured {@link overrideOf} value to stamp. - */ - stampOverride(child: Session, mode: SandboxMode): void { - if (effectiveSandboxMode(child.events) === mode) return - setSandboxMode(child, mode) + const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) + if (own !== undefined) return own + const baseline = session.header.sandboxMode + if (baseline === undefined) return undefined + if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { + throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) + } + return baseline as SandboxMode } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index e5bfb59778..590497563b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -143,41 +143,56 @@ describe('the sandbox/mode session kit', () => { }) }) -describe('delegation inheritance (overrideOf + stampOverride)', () => { - const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode') +describe('delegation inheritance (overrideOf over the header baseline)', () => { + /** A session whose header carries the delegation-inheritance baseline. */ + function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, + ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + }) + } - it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => { + it('overrideOf folds the session log and never falls back to the deployment default', async () => { const ctx = await mounted({ mode: 'workspace-write' }) const parent = session('sess-inherit-parent') setSandboxMode(parent, 'workspace-write') setSandboxMode(parent, 'read-only') expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only') - // undefined, NOT the deployment default — a child stamped with the + // undefined, NOT the deployment default — a child whose header froze the // default would stop following the LIVE default across resumes. expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined() }) - it('stampOverride appends the captured mode through the canonical write path', async () => { - const ctx = await mounted() - const child = session('sess-inherit-child') + it('overrideOf reads the header baseline when the log has no own switch', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' }) - ctx.sandboxPolicy.stampOverride(child, 'read-only') - - const stamped = modeEvents(child) - expect(stamped).toHaveLength(1) - expect(stamped[0]?.data).toEqual({ mode: 'read-only' }) + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + // resolve() consumes the same chain, so enforcement sees the baseline. + expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') }) - it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => { + it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + // The fork seed carried the parent's OLD workspace-write switch (one + // event, so seedLength 1); the delegation-time baseline is read-only. + const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 }) + setSandboxMode(child, 'workspace-write') + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + // A switch the child makes ITSELF (after the seed boundary) outranks it. + setSandboxMode(child, 'danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access') + }) + + it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => { const ctx = await mounted() - const child = session('sess-inherit-dedup-child') - // A fork seed can already carry the parent's switch; stamping again would - // append a redundant event on every delegation. - setSandboxMode(child, 'read-only') + const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' }) - ctx.sandboxPolicy.stampOverride(child, 'read-only') - - expect(modeEvents(child)).toHaveLength(1) + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index ecaf2cea28..17a8695dcf 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/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: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d -README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1 +README.md: 75540b957230676850a7abaf7a58877ff7dcbcc5 +README.zh.md: d82d5f8d0ef263aa3506ed56b7a2b0a83a1cb99d diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index a0d718cf8b..75540b9572 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index 307bef8efb..d82d5f8d0e 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或仅在 `packChunks` 下写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5018e19dbf..0d566d37c5 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -38,6 +38,8 @@ export interface HeaderLine { parentSession?: SessionId seedLength?: number delegationDepth: number + sandboxMode?: string + approvalPolicy?: string } /** @@ -55,6 +57,8 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, delegationDepth: header.delegationDepth ?? 0, + ...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {}, + ...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {}, } } @@ -72,6 +76,8 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, delegationDepth: line.delegationDepth, + ...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {}, + ...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {}, } } @@ -90,6 +96,10 @@ function isHeaderLine(value: unknown): value is HeaderLine { && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) + && ((value as { sandboxMode?: unknown }).sandboxMode === undefined + || typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string') + && ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined + || typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string') ) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index f771b9e3a7..8d98fbdf2d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -301,15 +301,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, parent_session = excluded.parent_session, seed_length = excluded.seed_length, - delegation_depth = excluded.delegation_depth + delegation_depth = excluded.delegation_depth, + sandbox_mode = excluded.sandbox_mode, + approval_policy = excluded.approval_policy `).run( meta.id, meta.version, @@ -319,6 +321,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.delegationDepth ?? null, randomUUID(), + meta.sandboxMode ?? null, + meta.approvalPolicy ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..f8e3285ba2 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 10 +export const SCHEMA_VERSION = 11 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -41,6 +41,10 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null + /** The inherited sandbox-mode delegation baseline, or NULL. */ + sandbox_mode: string | null + /** The inherited approval-policy delegation baseline, or NULL. */ + approval_policy: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -124,7 +128,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, delegation_depth INTEGER, incarnation TEXT NOT NULL, - revision INTEGER NOT NULL + revision INTEGER NOT NULL, + sandbox_mode TEXT, + approval_policy TEXT ) STRICT; CREATE TABLE IF NOT EXISTS events ( @@ -181,6 +187,8 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {}, + ...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 09d43d0c7f..e0455b659c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -170,6 +170,8 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, + sandbox_mode: null, + approval_policy: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) }) @@ -606,7 +608,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(10) + expect(SCHEMA_VERSION).toBe(11) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 227a742f7a..ed247edadb 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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: 31b917f7edc08182c72b1e4c0ed403a155dc477f -README.zh.md: 81cce8f804f5d6cb4a4915f3aecca278dd357db1 +README.md: 6563fbda00c8fc9a22be5f34ffb327d5876e1c05 +README.zh.md: f0cd20b3a5c40cb76141ff1fb0b6cd1f5f6b59f5 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 31b917f7ed..6563fbda00 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,7 +18,7 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. -The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and a one-shot PREPENDED `agent/prompt-submit` listener stamps the captured values through `stampOverride` (both services consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request; prepending puts it before veto-capable listeners, so a denying UserPromptSubmit hook cannot close the first turn without the stamp; its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing. Only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each capture folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 81cce8f804..f0cd20b3a5 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,7 +18,7 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 -子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——再由一个一次性、前置安装的 `agent/prompt-submit` 监听器通过 `stampOverride` 盖章写入捕获值(两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派)。锚定在子 agent 的第一个轮次内,使盖章事件被包围在轮次内(具备持久性)并先于第一次请求;前置安装使其位于具备否决能力的监听器之前,因此作出拒绝的 UserPromptSubmit 钩子无法在未盖章的情况下关闭第一个轮次;盖章事件在日志中位于 fork 初始内容携带的任何切换之后,因此既有的「最后一个事件生效」折叠即可解析陈旧初始内容的时序。只复制覆盖链,因此未切换过的父 agent 不盖任何章,子 agent 继续跟随实时部署默认值。嵌套按构造即可组合:每次捕获折叠的都是发起委派的会话已经盖过章的日志(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值;`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 1f02151a3b..d7df893ccc 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -100,16 +100,17 @@ export async function startInProcessRun( subagentDepth: childDepth, } - // Policy inheritance, read half: capture the parent's sandbox/approval - // OVERRIDES synchronously, before the first await — the delegation moment - // is the semantic snapshot point, and a parent switch racing the child's + // Policy inheritance: capture the parent's sandbox/approval OVERRIDES + // synchronously, before the first await — the delegation moment is the + // semantic snapshot point, and a parent switch racing the child's // asynchronous creation must belong to the parent's future, not the child. - // Both services are consumed opportunistically — without them, delegation - // stays policy-free. - const sandboxPolicy = parent.ctx.get('sandboxPolicy') - const approval = parent.ctx.get('approval') - const inheritedMode = sandboxPolicy?.overrideOf(parent.session) - const inheritedPolicy = approval?.overrideOf(parent.session) + // The captured values ride the child's creation meta into its immutable + // header, so the baseline is durable from the moment the session exists — + // no first-turn event could survive every crash window (an idle injection + // can persist a complete turn before any prompt turn opens). Both services + // are consumed opportunistically — without them, delegation is policy-free. + const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) + const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { @@ -120,23 +121,6 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } - // Write half: stamp the captured overrides once, anchored inside the - // child's FIRST turn (prompt-submit runs after turn/start, before prompt - // assembly) — a bare between-turn append would be crash-tail garbage on - // reload, and stamping here also orders the override after any stale - // switch a fork seed carried, so the ordinary last-event-wins fold - // resolves it. PREPENDED so a veto-capable listener (a denying - // UserPromptSubmit hook) cannot close the first turn without the stamp — - // the stamp must be durable even for a blocked first prompt. One-shot: - // later turns must not re-stamp over a switch the child made itself. - if (inheritedMode !== undefined || inheritedPolicy !== undefined) { - const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => { - disposeInherit() - if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode) - if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy) - return next() - }, { prepend: true }) - } } const flags = { cancelled: false } @@ -148,6 +132,8 @@ export async function startInProcessRun( // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, ...seedLength > 0 ? { seedLength } : {}, + ...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {}, + ...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {}, }, ...options.seed !== undefined ? { seed: options.seed } : {}, agentOptions, diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 7de5fefc24..49dfa03423 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -198,16 +198,13 @@ describe('sandbox-mode inheritance against the real fs fence', () => { expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - // The stamped override is the child's OWN durable, turn-enclosed record: - // after turn/start, before the first model request snapshot. - const events = child.session.events - const turnStart = events.findIndex(e => e.type === 'turn/start') - const mode = events.findIndex(e => e.type === 'sandbox/mode') - const policy = events.findIndex(e => e.type === 'approval/policy') - const header = events.findIndex(e => e.type === 'request/header') - expect(mode).toBeGreaterThan(turnStart) - expect(policy).toBeGreaterThan(turnStart) - expect(header).toBeGreaterThan(mode) + // The inherited baseline is part of the child's IMMUTABLE header — + // durable from the creation moment, with no first-turn timing window + // (a crash after any persisted turn still resumes with the baseline). + expect(child.session.header.sandboxMode).toBe('read-only') + expect(child.session.header.approvalPolicy).toBe('never') + // The log stays free of stamped events: the header is the one home. + expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) // What the enforcing families resolve for the child, end to end. expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') // Inheritance reads the parent log, never writes it. @@ -216,6 +213,33 @@ describe('sandbox-mode inheritance against the real fs fence', () => { await run.dispose() }) + it('the baseline is durable BEFORE any child turn exists (the injection-turn crash window)', async () => { + // The review scenario: a SessionStart-style idle injection can persist a + // complete turn before the first prompt turn opens. The baseline must + // already be durable then — it is, because it rides the creation-time + // header, not a first-turn event. + const script: Script = [] + const { parent } = await setupWalled(script) + script.push( + () => { + setSandboxMode(parent.session, 'read-only') + return textResponse('staged') + }, + textResponse('child done'), + ) + parent.followup([{ type: 'text', text: 'stage' }]) + await parent.whenIdle() + + const run = await startInProcessRun(spawnRequest(parent), {}) + const child = run.localAgent as Agent + // Assert on the HEADER immediately after publication — before the child's + // first turn has run (run.result not yet awaited). An idle injection + // persisting a turn now would carry the baseline with it. + expect(child.session.header.sandboxMode).toBe('read-only') + await run.result + await run.dispose() + }) + it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => { const script: Script = [] const captured: Agent[] = [] @@ -373,10 +397,11 @@ describe('inheritance survives prompt vetoes', () => { await run.result const child = run.localAgent as Agent - // The veto closed the first turn promptless, but the stamp is inside that - // turn regardless — a later resume must not fall back to the deployment - // default just because the first prompt was blocked. - expect(overrideEvents(child)).toEqual({ sandbox: 1, approval: 0 }) + // The veto closed the first turn promptless, but the baseline rides the + // creation-time header — no listener ordering can starve it, and a later + // resume must not fall back to the deployment default just because the + // first prompt was blocked. + expect(child.session.header.sandboxMode).toBe('read-only') expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') await run.dispose() @@ -399,7 +424,9 @@ describe('inheritance guards (must hold before AND after the fix)', () => { // workspace-write (the deployment default) really allowed the write… expect(await readFile(allowed, 'utf8')).toBe('fine') - // …and nothing froze that default into the child log. + // …and nothing froze that default into the child header or log. + expect(child.session.header.sandboxMode).toBeUndefined() + expect(child.session.header.approvalPolicy).toBeUndefined() expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) await run.dispose() diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 219d56beab..da878f5e88 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/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: c954333cc832f4fe9228f94fec36f4fbea4770fd -README.zh.md: d0955971fb9e72a08296b7c7f9f96d6d24d13044 +README.md: 537fda6e16c2e21d22a1809e1e91fc0c3ddeee94 +README.zh.md: 132b145ee3c7f7cea7aa0529358ed4982615499f diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index c954333cc8..537fda6e16 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index d0955971fb..132b145ee3 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`/`ctx.approval.stampOverride(child, policy)` 是委派继承的两半:仅折叠本身(绝不包含配置默认值),以及通过该写入路径写入捕获的覆盖项;进程内 subagent 驱动器在委派时捕获,并在子 agent 的第一个轮次内盖章,使 `'never'` 父级无法造出会弹出提示的子 agent(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)` 解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 06f94a5ddb..e3351a9771 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -323,35 +323,33 @@ export class ApprovalService extends Service { * @returns the policy every ask for this session resolves under right now. */ private effectivePolicy(session: Session): ApprovalPolicy { - return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask' + return this.overrideOf(session) ?? this.config.policy ?? 'ask' } /** - * A session's approval-policy OVERRIDE — the fold alone, never the - * configured default. The read half of delegation inheritance: the subagent - * driver captures this synchronously at delegation, so a parent switch - * racing the child's asynchronous creation belongs to the parent's future, - * not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - * @param session - the session whose override chain to fold. - * @returns the last switched policy, or `undefined` for a never-switched session. + * A session's approval-policy OVERRIDE — the override chain alone, never + * the configured default: the fold of the session's OWN switches (events + * past the seed boundary — a fork seed's stale parent switch is subsumed by + * the baseline captured after it), else the header's inherited delegation + * baseline. The subagent driver stamps `overrideOf(parent.session)` into + * each child's creation meta, so a `'never'` (headless/CI) parent cannot + * mint children that fall back to a prompting default, at any depth + * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the configured default. + * @throws when the durable header baseline is outside the closed policy + * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) - } - - /** - * Stamp a captured override onto a child session through the canonical - * write path — the write half of delegation inheritance: a `'never'` - * (headless/CI) parent must not mint children that fall back to a prompting - * default. A child whose log (e.g. a fork seed) already folds to the policy - * is left untouched. Callers must append inside an open child turn — a bare - * between-turn event is crash-tail garbage on reload. - * @param child - the child session the override is appended to. - * @param policy - the captured {@link overrideOf} value to stamp. - */ - stampOverride(child: Session, policy: ApprovalPolicy): void { - if (effectiveApprovalPolicy(child.events) === policy) return - setApprovalPolicy(child, policy) + const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) + if (own !== undefined) return own + const baseline = session.header.approvalPolicy + if (baseline === undefined) return undefined + if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { + throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) + } + return baseline as ApprovalPolicy } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 9b1664c27c..849185daee 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -20,6 +20,9 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty const agent = { session: { events: seed, + // The typed Session contract the service folds over includes the header + // (seed boundary + inherited baselines); the stub carries a bare one. + header: { version: 0, id: 'fake-session', createdAt: 0 }, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -577,14 +580,24 @@ describe('approval policy (the approval/policy fold)', () => { }) }) -describe('delegation inheritance (overrideOf + stampOverride)', () => { - const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy') - +describe('delegation inheritance (overrideOf over the header baseline)', () => { function bareSession(id: string): Session { return new Session(SessionId(id)) } - it('overrideOf folds to the LAST override and never falls back to the configured default', async () => { + /** A session whose header carries the delegation-inheritance baseline. */ + function inheritedSession(id: string, meta: { approvalPolicy?: string; seedLength?: number } = {}): Session { + const sessionId = SessionId(id) + return new Session(sessionId, undefined, { + version: 0, + id: sessionId, + createdAt: 0, + ...meta.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, + ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + }) + } + + it('overrideOf folds the session log and never falls back to the configured default', async () => { const ctx = await mounted() const parent = bareSession('sess-appr-inherit-parent') setApprovalPolicy(parent, 'never') @@ -593,24 +606,34 @@ describe('delegation inheritance (overrideOf + stampOverride)', () => { expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() }) - it('stampOverride appends the captured policy through the canonical write path', async () => { + it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => { const ctx = await mounted() - const child = bareSession('sess-appr-inherit-child') + const child = inheritedSession('sess-appr-baseline', { approvalPolicy: 'never' }) - ctx.approval.stampOverride(child, 'never') - - const stamped = policyEvents(child) - expect(stamped).toHaveLength(1) - expect(stamped[0]?.data).toEqual({ policy: 'never' }) + expect(ctx.approval.overrideOf(child)).toBe('never') + // The request path consumes the same chain: an inherited 'never' rejects + // deterministically before any answerer could run. + child.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session: child } as unknown as Agent + await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('rejected') }) - it('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => { + it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { const ctx = await mounted() - const child = bareSession('sess-appr-dedup-child') - setApprovalPolicy(child, 'never') + const child = inheritedSession('sess-appr-slice', { approvalPolicy: 'never', seedLength: 1 }) + // Event 0 sits inside the seed boundary — stale parent history, subsumed + // by the delegation-time baseline. + setApprovalPolicy(child, 'ask') + expect(ctx.approval.overrideOf(child)).toBe('never') + // Event 1 is the child's OWN switch — it outranks the baseline. + setApprovalPolicy(child, 'ask') + expect(ctx.approval.overrideOf(child)).toBe('ask') + }) - ctx.approval.stampOverride(child, 'never') + it('rejects a header baseline outside the closed policy vocabulary (durable boundary)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-invalid', { approvalPolicy: 'always' }) - expect(policyEvents(child)).toHaveLength(1) + expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) }) From 12a05e2692b7fef4224899c9c9172e83df8a6ca9 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 18:21:24 +0800 Subject: [PATCH 07/61] test(snapshot): refresh the cordis-inspect fixture for the header baseline fields The cordis_inspect api surface embeds the SessionHeader declaration, which gained sandboxMode/approvalPolicy; keyless refresh, model transcript untouched. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..bdbe34d718 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n 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];\n 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': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\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 provenance: AssistantProvenance;\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?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n 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];\n 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': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\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 provenance: AssistantProvenance;\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?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From ffb6435a41703f04473c24198e43cf5533f42cb1 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 19:30:27 +0800 Subject: [PATCH 08/61] test(session): cover the header baseline validation and persistence round-trip CI per-file coverage flagged the new sandboxMode/approvalPolicy branches: invalid-type cases in both session header validation tables, and a shared persistence-contract case pinning that both backends round-trip the baselines verbatim and keep absent fields ABSENT (presence is the signal the policy owners branch on). --- packages/core/session/tests/session.spec.ts | 4 +++ .../session-persistence/tests/contract.ts | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e649214a29..f1147396c9 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -764,6 +764,8 @@ describe('Session', () => { { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + { header: { ...base, sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, + { header: { ...base, approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const { header, error } of cases) { @@ -1011,6 +1013,8 @@ describe('SessionStore', () => { { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, + { meta: { sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, + { meta: { approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 88d1eef68e..4508e0ccaa 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -84,6 +84,36 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + // A delegated child header: the sandbox/approval baselines must + // survive storage verbatim — a resumed child falling back to the + // deployment default would reopen the delegation bypass. + const child: SessionHeader = { + ...meta('s-baseline', '/work'), + delegationDepth: 1, + sandboxMode: 'read-only', + approvalPolicy: 'never', + } + await persistence.create(child) + await persistence.append(child.id, oneTurnLog()) + const loaded = await persistence.load(child.id) + expect(loaded.meta).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) + + // A top-level header: absent baselines stay ABSENT (not null/empty) — + // presence is the signal the policy owners branch on. + const top = meta('s-no-baseline', '/work') + await persistence.create(top) + await persistence.append(top.id, oneTurnLog()) + const reloaded = await persistence.load(top.id) + expect('sandboxMode' in reloaded.meta).toBe(false) + expect('approvalPolicy' in reloaded.meta).toBe(false) + } finally { + await dispose() + } + }) + it('rejects a fractional creation timestamp without reserving its session id', async () => { const { persistence, dispose } = await make() try { From f505bd9258c1766d6bd0625ddf8b65e634754914 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 22:02:02 +0800 Subject: [PATCH 09/61] policy: resolve every knob consumer through the shared override chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (ds-review-bot warnings on #623): - One chain, every consumer: the override resolution (own post-seed switches ?? header baseline, closed-vocabulary validated) moves into pure exports (sandboxOverrideOf / approvalOverrideOf); the services delegate, and the permission presets consume them — current(session) and set() now see inherited baselines, so a child inheriting danger-full-access gets REAL knob switches when workspace-write is selected instead of a silent no-op, and a seed-carried preset selection is subsumed by the baseline. current(events) becomes current(session) (pre-release; the only callers were tests). - Unconditional durable validation: a malformed header baseline fails loud on every read, no longer shadowed by an own switch. - The two policy peers are declared optional (peerDependenciesMeta), so a thin spawn/fork deployment without policy plugins can consume the driver; verify-runtime-closure honors the flag. Red-first: inherited-preset derive/switch-away and seeded-selection tests in the permission suite; malformed-baseline-with-own-switch tests in both policy suites. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 19 ++++--- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- packages/sandbox/sandbox-policy/src/index.ts | 13 +---- .../sandbox-policy/src/session-mode.ts | 23 ++++++++ .../sandbox-policy/tests/policy.spec.ts | 10 ++++ .../subagent/subagent-inprocess/package.json | 8 +++ packages/ui/permission/README.i18n.yaml | 4 +- packages/ui/permission/README.md | 2 +- packages/ui/permission/README.zh.md | 4 +- packages/ui/permission/src/index.ts | 36 +++++++----- .../ui/permission/tests/permission.spec.ts | 57 ++++++++++++++++--- packages/ui/user-approval/README.i18n.yaml | 4 +- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/README.zh.md | 2 +- packages/ui/user-approval/src/index.ts | 32 ++++++++--- .../ui/user-approval/tests/approval.spec.ts | 8 +++ 23 files changed, 179 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 1de32450b5..b0411bbf88 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: a15a20aeb8324f00fcb9ca85aac5d5e2352dfac3 -2026-07-25-subagent-policy-inheritance.zh.md: 2ac844fdd9bbb1b4a57e76a96a5cd5270c23d2aa +2026-07-25-subagent-policy-inheritance.md: 11086232bfe6a81ce5579d9459187f2931b6e387 +2026-07-25-subagent-policy-inheritance.zh.md: 63b2ebb2373b7a94f1b630df98b38325a045b9c0 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index a15a20aeb8..11086232bf 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -13,7 +13,7 @@ Session policy overrides are per-session log folds: the effective sandbox mode i The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the `delegationDepth` precedent: - **Capture synchronously at delegation, persist in the creation-time header.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. -- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — on `SandboxPolicyService` and `ApprovalService` — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary on every read (durable-boundary validation) and throws on foreign values. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged. +- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — the pure `sandboxOverrideOf`/`approvalOverrideOf` exports, surfaced as service methods — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (`resolve()`, pty-local) and the permission presets (`current`/`set`), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (`ctx.get`, type-only imports, `peerDependenciesMeta.optional`): compositions without them delegate policy-free, unchanged. - **Fork stale-seed precedence falls out of the seed boundary.** A fork seed may carry the parent's OLD switch events; `overrideOf` folds only events past `header.seedLength`, so seed-carried history is subsumed by the delegation-time baseline while a switch the child makes ITSELF still outranks it. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. - **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, so they can never leak down the chain. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 2ac844fdd9..63b2ebb237 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -13,7 +13,7 @@ Status: implemented 共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: - **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 -- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——位于 `SandboxPolicyService` 与 `ApprovalService` 上——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都按自己的封闭词汇校验(持久边界校验),遇到词汇之外的值即抛出异常。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入):未挂载它们的组合照旧进行无策略委派,行为不变。 +- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——即纯函数导出 `sandboxOverrideOf`/`approvalOverrideOf`,以服务方法的形式暴露——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都无条件按自己的封闭词汇校验(即便自己做出的切换会遮蔽基线,损坏的会话头也会大声失败),遇到词汇之外的值即抛出异常。每一个旋钮消费方都经由同一条链解析——强制执行侧(`resolve()`、pty-local)与权限 preset(`current`/`set`)皆然——因此当选中更窄的 preset 时,继承了更宽基线的子 agent 得到的是真实的旋钮切换,而非静默的空操作。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入,`peerDependenciesMeta.optional`):未挂载它们的组合照旧进行无策略委派,行为不变。 - **fork 陈旧种子的优先级由种子边界自然得出。**fork 种子可能携带父级旧的切换事件;`overrideOf` 只折叠 `header.seedLength` 之后的事件,因此种子携带的历史已被委派时的基线所涵盖,而子 agent 自己做出的切换仍然优先于基线。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 - **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9880761894..c6b2a68258 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,7 +1862,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:221`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2a137c2f70..6804e87819 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -263,7 +263,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:236`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -781,13 +781,18 @@ Owns the deployment's permission presets and their write path. Requires a confin ```ts cordis-catalog /** - * Resolve the preset matching the effective knob values. A still-matching - * last selection wins shared-bundle ties; otherwise the first table match - * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * Resolve the preset matching the effective knob values — the same + * override chains execution reads (own post-seed switches, else the + * inherited header baseline, else the composition defaults), so a + * delegated child's inherited knobs derive its real preset. A + * still-matching last OWN selection wins shared-bundle ties (a seed-carried + * selection is stale parent history, subsumed by the baseline); otherwise + * the first table match wins, or {@link CUSTOM_PRESET} when no entry + * matches. + * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Resolve a preset's knob bundle. @@ -815,7 +820,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) +Types: [Session](../core-data-structures/session.md) Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a586702cc7..b801d47443 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -399,8 +399,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Owns the deployment\'s permission presets and their write path.', methods: [ { - signature: 'current(events: readonly SessionEvent[]): string', - jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + signature: 'current(session: Session): string', + jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last OWN selection wins shared-bundle ties (a seed-carried\n * selection is stale parent history, subsumed by the baseline); otherwise\n * the first table match wins, or {@link CUSTOM_PRESET} when no entry\n * matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, { signature: 'resolve(name: string): PresetSpec', diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 778b213f3d..b93b3a3311 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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: 733a14141a8a67728b026c7a19d18266b61cbf6a -README.zh.md: 916baf00b64a47fd4b646f1c25aaf5caf2fa56b5 +README.md: 2088574df7145b72c130bdc4f8f841517afb004b +README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 733a14141a..2088574df7 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -19,7 +19,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. -- `ctx.sandboxPolicy.overrideOf(session)` — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 916baf00b6..e44ab8949f 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -19,7 +19,7 @@ - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 - `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `ctx.sandboxPolicy.overrideOf(session)`:会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 46124f2373..317d0f971e 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,9 +19,9 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import { SANDBOX_MODES, effectiveSandboxMode } from './session-mode.ts' +import { sandboxOverrideOf } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, effectiveSandboxMode, sandboxOverrideOf, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -121,14 +121,7 @@ export class SandboxPolicyService extends Service { * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined { - const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) - if (own !== undefined) return own - const baseline = session.header.sandboxMode - if (baseline === undefined) return undefined - if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { - throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) - } - return baseline as SandboxMode + return sandboxOverrideOf(session) } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index a97532b29b..28a4234b26 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -54,6 +54,29 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo return undefined } +/** + * The session's complete sandbox-mode OVERRIDE chain — the one home every + * consumer (the policy service, the permission presets) resolves through: + * the fold of the session's OWN switches (events past the seed boundary — a + * fork seed's stale parent switch is subsumed by the delegation baseline + * captured after it), else the header's inherited baseline. Never the + * deployment default. The durable baseline is validated UNCONDITIONALLY — a + * corrupt or foreign header must fail loud on every read, not only when no + * own switch happens to shadow it. + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the deployment default. + * @throws when the header baseline is outside the closed mode vocabulary. + */ +export function sandboxOverrideOf(session: Session): SandboxMode | undefined { + const baseline = session.header.sandboxMode + if (baseline !== undefined && !SANDBOX_MODES.includes(baseline as SandboxMode)) { + throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) + } + const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) + return own ?? baseline as SandboxMode | undefined +} + /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 590497563b..3d1f1ae47d 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -195,4 +195,14 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) + + it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-inherit-invalid-own', { sandboxMode: 'yolo' }) + // A corrupt or foreign durable record must fail loud on EVERY read — an + // own override must not paper over the malformed header. + setSandboxMode(child, 'read-only') + + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) + }) }) diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index f78e944b02..893fd4e342 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -38,6 +38,14 @@ "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index c29bf60910..e7ff3fa2a7 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/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: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa -README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e +README.md: 2d01844d8391a530ec06878c0b77b20bf74d6f12 +README.zh.md: 4456f53291b64ce14a6eaea5204050ecfc043560 diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 6a59ad9425..2d01844d83 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. Both it and `current(session)` resolve the knobs through the same override chains execution reads (`sandboxOverrideOf`/`approvalOverrideOf`: own post-seed switches, else the inherited header baseline, else composition defaults), so a delegated child inheriting a wider baseline gets real knob switches when a narrower preset is selected, and a seed-carried selection is subsumed by the baseline. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` prefers a still-matching recorded own selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 0e7db1bd41..4456f53291 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -4,7 +4,7 @@ 通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。它与 `current(session)` 都通过执行所读取的同一套覆盖链解析调节项(`sandboxOverrideOf`/`approvalOverrideOf`:先取会话自己在种子之后的切换,否则取会话头中继承的基线,否则取组合默认值),因此继承了更宽基线的被委派子 agent(智能体)在选中更窄的 preset 时会得到真实的调节项切换,而种子携带的选择会被基线所涵盖。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 优先返回仍与当前调节项匹配的、会话自己的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 -- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 +- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d44dff3df4..8d89472abd 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -12,12 +12,12 @@ 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-sandbox-policy' +import { SANDBOX_MODES, sandboxOverrideOf, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.bash` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} 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' +import { APPROVAL_POLICIES, approvalOverrideOf, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' declare module 'cordis' { interface Context { @@ -139,17 +139,22 @@ export class PermissionService extends Service { } /** - * Resolve the preset matching the effective knob values. A still-matching - * last selection wins shared-bundle ties; otherwise the first table match - * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * Resolve the preset matching the effective knob values — the same + * override chains execution reads (own post-seed switches, else the + * inherited header baseline, else the composition defaults), so a + * delegated child's inherited knobs derive its real preset. A + * still-matching last OWN selection wins shared-bundle ties (a seed-carried + * selection is stale parent history, subsumed by the baseline); otherwise + * the first table match wins, or {@link CUSTOM_PRESET} when no entry + * matches. + * @param session - the session whose preset to derive. * @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' + current(session: Session): string { + const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode + const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(events) + const folded = effectivePermissionPreset(session.events.slice(session.header.seedLength ?? 0)) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded @@ -197,14 +202,17 @@ export class PermissionService extends Service { */ set(session: Session, name: string): void { const spec = this.resolve(name) - if (this.current(session.events) !== name) { + if (this.current(session) !== name) { session.append('permission/preset', { preset: name }) } - const events = session.events - if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) { + // Compare against the SAME override chains current() derives from: a + // child inheriting a wider baseline must get real knob switches when the + // user selects a narrower preset — an event-only fold would believe the + // preset is already active and silently leave enforcement at the baseline. + if (spec.sandbox !== (sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask')) { setApprovalPolicy(session, spec.approval) } } diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 864f25629d..4133ee7bbf 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -48,25 +48,25 @@ describe('PermissionService', () => { 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('workspace-write') + expect(ctx.permission.current(session)).toBe('workspace-write') ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') }) 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('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') 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) + expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) }) it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { @@ -77,10 +77,10 @@ describe('PermissionService', () => { } } }) const session = freshSession('sess-tie') ctx.permission.set(session, 'agentish') - expect(ctx.permission.current(session.events)).toBe('agentish') + expect(ctx.permission.current(session)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permission.current(session.events)).toBe('danger-full-access') + expect(ctx.permission.current(session)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -140,6 +140,47 @@ describe('PermissionService', () => { const session = freshSession('sess-standin') ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permission.current(session.events)).toBe('workspace-write') + expect(ctx.permission.current(session)).toBe('workspace-write') + }) + + it('derives current() from an inherited header baseline and switches AWAY from it for real', async () => { + const ctx = await mounted() + // A delegated child: danger-full-access baseline over the composition's + // workspace-write/ask defaults — the child header, not the event log, + // carries the effective knobs. + const id = SessionId('sess-inherited-preset') + const child = new Session(id, undefined, { + version: 0, + id, + createdAt: 0, + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + }) + expect(ctx.permission.current(child)).toBe('danger-full-access') + + // Selecting workspace-write must APPEND both knob switches: folding only + // events would believe workspace-write is already active and silently + // leave enforcement at the inherited danger-full-access. + ctx.permission.set(child, 'workspace-write') + expect(child.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) + expect(child.events.some(e => e.type === 'approval/policy' && e.data.policy === 'ask')).toBe(true) + expect(ctx.permission.current(child)).toBe('workspace-write') + }) + + it('ignores a seed-carried preset selection in favor of the delegation baseline', async () => { + const ctx = await mounted() + const id = SessionId('sess-seeded-preset') + const seeded = new Session(id, undefined, { + version: 0, + id, + createdAt: 0, + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + seedLength: 1, + }) + // The fork seed carried the PARENT's old selection event; the baseline + // captured after it owns the child's truth. + seeded.append('permission/preset', { preset: 'workspace-write' }) + expect(ctx.permission.current(seeded)).toBe('danger-full-access') }) }) diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index da878f5e88..bf32de8dac 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/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: 537fda6e16c2e21d22a1809e1e91fc0c3ddeee94 -README.zh.md: 132b145ee3c7f7cea7aa0529358ed4982615499f +README.md: f6eaf049a46bc2faa7e0b12809d3d8c63d7792d6 +README.zh.md: a286d13eb7c5a3e7893a8269c6d50175eff47046 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 537fda6e16..f6eaf049a4 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index 132b145ee3..a286d13eb7 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)` 解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index e3351a9771..4a6d40ca1c 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -138,6 +138,29 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } +/** + * The session's complete approval-policy OVERRIDE chain — the one home every + * consumer (this service's policy tier, the permission presets) resolves + * through: the fold of the session's OWN switches (events past the seed + * boundary — a fork seed's stale parent switch is subsumed by the delegation + * baseline captured after it), else the header's inherited baseline. Never + * the configured default. The durable baseline is validated UNCONDITIONALLY — + * a corrupt or foreign header must fail loud on every read, not only when no + * own switch happens to shadow it. + * @param session - the session whose override chain to resolve. + * @returns the effective override, or `undefined` for a session following + * the configured default. + * @throws when the header baseline is outside the closed policy vocabulary. + */ +export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { + const baseline = session.header.approvalPolicy + if (baseline !== undefined && !APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { + throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) + } + const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) + return own ?? baseline as ApprovalPolicy | undefined +} + /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -342,14 +365,7 @@ export class ApprovalService extends Service { * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined { - const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) - if (own !== undefined) return own - const baseline = session.header.approvalPolicy - if (baseline === undefined) return undefined - if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { - throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) - } - return baseline as ApprovalPolicy + return approvalOverrideOf(session) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 849185daee..f4ada96338 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -636,4 +636,12 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) + + it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-invalid-own', { approvalPolicy: 'always' }) + setApprovalPolicy(child, 'never') + + expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) + }) }) From 1a11c0dbcfe8fab0d022413ce5f836deeb3a117f Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 22:07:05 +0800 Subject: [PATCH 10/61] docs(notes): record the parent-only-override snapshot path and pickup shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot-coverage consequence names the deployment-strength limit, why the automation protocol cannot express a parent-only override, and the headless semantic-checkpoint precedent (seeded parent log + resume fixture plugin) as the pickup shape — so the deferred scenario is not re-derived. --- .../feature/2026-07-25-subagent-policy-inheritance.i18n.yaml | 4 ++-- .../feature/2026-07-25-subagent-policy-inheritance.md | 1 + .../feature/2026-07-25-subagent-policy-inheritance.zh.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index b0411bbf88..0e4b5dbbd6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: 11086232bfe6a81ce5579d9459187f2931b6e387 -2026-07-25-subagent-policy-inheritance.zh.md: 63b2ebb2373b7a94f1b630df98b38325a045b9c0 +2026-07-25-subagent-policy-inheritance.md: 8609fee90f3f9ea73f5e6229a38cde5e61337144 +2026-07-25-subagent-policy-inheritance.zh.md: ad1b4650348154d82473b17d920dff475833f00e diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 11086232bf..8609fee90f 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -34,4 +34,5 @@ A confined child that hits the wall gets the ordinary denial marker; an escalati - A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). - The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. - Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. +- Snapshot coverage runs at deployment strength only: the recorded `subagent-sandbox-inheritance` scenario proves a delegated child confined under a read-only DEPLOYMENT policy through the assembled ACP app, but the automation-only protocol has no session-scoped switch, so a parent-ONLY override (wider default, tightened parent, denied child) is not expressible there. When picked up, the shape is the headless suite's semantic-checkpoint precedent: seed a persisted parent log carrying a real `sandbox/mode` switch, resume it through the Loader-booted cli-demo app via a resume fixture plugin, and let it delegate — a scenario-harness addition (the headless suite has no subagent+sandbox overlay yet), not a new mechanism. - `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 63b2ebb237..ad1b465034 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -34,4 +34,5 @@ Status: implemented - 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 - 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 - 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 +- 快照覆盖只运行在部署强度上:已录制的 `subagent-sandbox-inheritance` 场景经由组装后的 ACP 应用,证明了一个被委派的子 agent 被约束在只读的部署级策略之下,但这个仅面向自动化的协议没有会话作用域的切换,因此仅父级的覆盖项(更宽的默认值、收紧的父级、被拒的子 agent)在该协议上无法表达。待接手时,其形态沿用 headless 套件的语义检查点先例:预置一份携带真实 `sandbox/mode` 切换的已持久化父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它,再让它发起委派——这是一次场景 harness 的扩充(headless 套件尚无 subagent+沙箱 overlay),而非新机制。 - `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 From b02d3e4c03f936c83ae877af5749ee716ee3da4a Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 22:32:24 +0800 Subject: [PATCH 11/61] test(subagent): drop the vacuous fork-dedup case; align wording with the header baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedup guard pinned 'no duplicate stamp' — under the header baseline no stamping exists, so its event-count assertion became a tautology and its fence assertions a subset of the stale-seed case. The spec docblock claimed the session invariants pin the stamping design (superseded); it now names the three review-found timing threats the suite pins as distinct shapes. Service overrideOf JSDoc shrinks to a pointer at the owning pure export. --- docs/cordis-catalog/services.md | 26 +++--------- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/sandbox/sandbox-policy/src/index.ts | 13 ++---- .../tests/inheritance.spec.ts | 41 ++++--------------- packages/ui/user-approval/src/index.ts | 13 ++---- 5 files changed, 21 insertions(+), 76 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6804e87819..024994840e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -244,19 +244,12 @@ Approval service that applies session policy before answerers and logs every ask async request(req: ApprovalRequest): Promise /** - * A session's approval-policy OVERRIDE — the override chain alone, never - * the configured default: the fold of the session's OWN switches (events - * past the seed boundary — a fork seed's stale parent switch is subsumed by - * the baseline captured after it), else the header's inherited delegation - * baseline. The subagent driver stamps `overrideOf(parent.session)` into - * each child's creation meta, so a `'never'` (headless/CI) parent cannot - * mint children that fall back to a prompting default, at any depth - * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * {@link approvalOverrideOf} surfaced on the service, for consumers that + * reach the seam through `ctx.get('approval')` (the subagent driver's + * delegation capture) rather than a value import. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the configured default. - * @throws when the durable header baseline is outside the closed policy - * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined ``` @@ -973,19 +966,12 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy /** - * A session's sandbox-mode OVERRIDE — the override chain alone, never the - * deployment default: the fold of the session's OWN switches (events past - * the seed boundary — a fork seed's stale parent switch is subsumed by the - * baseline captured after it), else the header's inherited delegation - * baseline. The subagent driver stamps `overrideOf(parent.session)` into - * each child's creation meta, so the chain collapses one level per - * delegation and a tightened parent binds children at any depth - * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * {@link sandboxOverrideOf} surfaced on the service, for consumers that + * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's + * delegation capture, pty-local) rather than a value import. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the deployment default. - * @throws when the durable header baseline is outside the closed mode - * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b801d47443..0028a153fd 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,7 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - jsDoc: '/**\n * A session\'s approval-policy OVERRIDE — the override chain alone, never\n * the configured default: the fold of the session\'s OWN switches (events\n * past the seed boundary — a fork seed\'s stale parent switch is subsumed by\n * the baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so a `\'never\'` (headless/CI) parent cannot\n * mint children that fall back to a prompting default, at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n * @throws when the durable header baseline is outside the closed policy\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', + jsDoc: '/**\n * {@link approvalOverrideOf} surfaced on the service, for consumers that\n * reach the seam through `ctx.get(\'approval\')` (the subagent driver\'s\n * delegation capture) rather than a value import.\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n */', }, ], }, @@ -492,7 +492,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', - jsDoc: '/**\n * A session\'s sandbox-mode OVERRIDE — the override chain alone, never the\n * deployment default: the fold of the session\'s OWN switches (events past\n * the seed boundary — a fork seed\'s stale parent switch is subsumed by the\n * baseline captured after it), else the header\'s inherited delegation\n * baseline. The subagent driver stamps `overrideOf(parent.session)` into\n * each child\'s creation meta, so the chain collapses one level per\n * delegation and a tightened parent binds children at any depth\n * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n * @throws when the durable header baseline is outside the closed mode\n * vocabulary (a corrupt or foreign log; durable-boundary validation).\n */', + jsDoc: '/**\n * {@link sandboxOverrideOf} surfaced on the service, for consumers that\n * reach policy through `ctx.get(\'sandboxPolicy\')` (the subagent driver\'s\n * delegation capture, pty-local) rather than a value import.\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n */', }, ], }, diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 317d0f971e..2dba47a643 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -106,19 +106,12 @@ export class SandboxPolicyService extends Service { } /** - * A session's sandbox-mode OVERRIDE — the override chain alone, never the - * deployment default: the fold of the session's OWN switches (events past - * the seed boundary — a fork seed's stale parent switch is subsumed by the - * baseline captured after it), else the header's inherited delegation - * baseline. The subagent driver stamps `overrideOf(parent.session)` into - * each child's creation meta, so the chain collapses one level per - * delegation and a tightened parent binds children at any depth - * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * {@link sandboxOverrideOf} surfaced on the service, for consumers that + * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's + * delegation capture, pty-local) rather than a value import. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the deployment default. - * @throws when the durable header baseline is outside the closed mode - * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): SandboxMode | undefined { return sandboxOverrideOf(session) diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 49dfa03423..f4f0f85318 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -8,9 +8,11 @@ * * Red/green anchor for the delegation-bypass gap: a parent switched to * `read-only` must not mint children that run under the (wider) deployment - * default. The stamping design is itself pinned by the mounted session - * invariants: an implementation that appends the inherited override OUTSIDE - * the child's first turn fails these suites through the turn-enclosure check. + * default. The captured overrides ride the child's creation-time header, so + * three review-found timing threats are pinned as distinct shapes: a parent + * switch racing the asynchronous creation, a veto-capable prompt-submit + * listener closing a promptless first turn, and an injection-persisted turn + * before any prompt turn opens (header asserted before the child runs). */ import { describe, expect, it, beforeEach, afterEach } from 'vitest' @@ -276,35 +278,6 @@ describe('sandbox-mode inheritance against the real fs fence', () => { expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') }) - it('a FORK child whose seed already folds to the parent mode gets NO duplicate stamp (guard)', async () => { - const script: Script = [] - const captured: Agent[] = [] - const { ctx, parent } = await setupWalled(script) - registerDelegate(ctx, captured) - const blocked = join(workspace, 'fork-dedup-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('turn one') - }, - toolCallResponse('d-fork', 'delegate', { fork: true }), - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('fork child done'), - textResponse('turn two done'), - ) - parent.followup([{ type: 'text', text: 'turn one' }]) - await parent.whenIdle() - parent.followup([{ type: 'text', text: 'turn two: delegate' }]) - await parent.whenIdle() - - const child = captured[0] as Agent - // The seed-carried override keeps enforcing… - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - // …and inheritance did not append a redundant copy on top of it. - expect(overrideEvents(child).sandbox).toBe(1) - }) - it('inherits the mode AT delegation, not a parent switch racing child creation', async () => { const script: Script = [] const captured: Agent[] = [] @@ -371,7 +344,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => { }) describe('inheritance survives prompt vetoes', () => { - it('stamps the child even when an earlier-registered prompt-submit listener vetoes without next()', async () => { + it('keeps the baseline when an earlier-registered prompt-submit listener vetoes without next()', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) // A veto-capable listener registered BEFORE the child exists — the @@ -409,7 +382,7 @@ describe('inheritance survives prompt vetoes', () => { }) describe('inheritance guards (must hold before AND after the fix)', () => { - it('a child of an unswitched parent runs under the live deployment default, with ZERO stamped events', async () => { + it('a child of an unswitched parent runs under the live deployment default, with NO baseline or events', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 4a6d40ca1c..2c44d674b5 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -350,19 +350,12 @@ export class ApprovalService extends Service { } /** - * A session's approval-policy OVERRIDE — the override chain alone, never - * the configured default: the fold of the session's OWN switches (events - * past the seed boundary — a fork seed's stale parent switch is subsumed by - * the baseline captured after it), else the header's inherited delegation - * baseline. The subagent driver stamps `overrideOf(parent.session)` into - * each child's creation meta, so a `'never'` (headless/CI) parent cannot - * mint children that fall back to a prompting default, at any depth - * ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). + * {@link approvalOverrideOf} surfaced on the service, for consumers that + * reach the seam through `ctx.get('approval')` (the subagent driver's + * delegation capture) rather than a value import. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the configured default. - * @throws when the durable header baseline is outside the closed policy - * vocabulary (a corrupt or foreign log; durable-boundary validation). */ overrideOf(session: Session): ApprovalPolicy | undefined { return approvalOverrideOf(session) From 99f5fab7bc0ff34eb3c93d24b7863030f9d25dff Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 23:06:15 +0800 Subject: [PATCH 12/61] policy: scope seed-boundary slicing to delegation children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (ds-review-bot on #623): the unconditional slice regressed the public SessionStore.fork path — a generic fork child gets seedLength but no policy baseline, so slicing discarded its seed-carried sandbox/approval switches and silently widened it to the deployment defaults (a forked read-only/never parent produced a workspace-write/ask child). overrideOf now branches on baseline presence: with a header baseline (a delegation child) the fold covers only own post-seed switches — the baseline captured from the parent's FULL log subsumes seed history; without one, the whole log — seeded switches ARE the replayed inherited truth. The permission preset fold scopes the same way. Red-first: generic-fork seed-carried override tests in both policy suites. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 ++-- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 11 +++++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- .../sandbox-policy/src/session-mode.ts | 23 +++++++++++-------- .../sandbox-policy/tests/policy.spec.ts | 13 +++++++++++ packages/ui/permission/src/index.ts | 12 ++++++---- packages/ui/user-approval/README.i18n.yaml | 4 ++-- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/README.zh.md | 2 +- packages/ui/user-approval/src/index.ts | 21 ++++++++++------- .../ui/user-approval/tests/approval.spec.ts | 11 +++++++++ 17 files changed, 78 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 0e4b5dbbd6..7084110b8d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md: 8609fee90f3f9ea73f5e6229a38cde5e61337144 -2026-07-25-subagent-policy-inheritance.zh.md: ad1b4650348154d82473b17d920dff475833f00e +2026-07-25-subagent-policy-inheritance.md: ea8e4626a1bd2ac31c2a7ce207bf9ba73e2e32fc +2026-07-25-subagent-policy-inheritance.zh.md: b9e978761af7de6ecf9a2e6ec167e2bb512c9ebf diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 8609fee90f..ea8e4626a1 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -14,7 +14,7 @@ The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent - **Capture synchronously at delegation, persist in the creation-time header.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. - **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — the pure `sandboxOverrideOf`/`approvalOverrideOf` exports, surfaced as service methods — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (`resolve()`, pty-local) and the permission presets (`current`/`set`), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (`ctx.get`, type-only imports, `peerDependenciesMeta.optional`): compositions without them delegate policy-free, unchanged. -- **Fork stale-seed precedence falls out of the seed boundary.** A fork seed may carry the parent's OLD switch events; `overrideOf` folds only events past `header.seedLength`, so seed-carried history is subsumed by the delegation-time baseline while a switch the child makes ITSELF still outranks it. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. +- **Fork stale-seed precedence falls out of the seed boundary — scoped to delegation children.** A fork seed may carry the parent's OLD switch events; when a header baseline exists, `overrideOf` folds only events past `header.seedLength` — the baseline was captured from the parent's FULL log at delegation, so seed-carried history is subsumed by it while a switch the child makes ITSELF still outranks it. Without a baseline (a top-level session, or a generic `SessionStore.fork` child that captured no policy meta) the fold covers the whole log: there, seeded switches ARE the replayed inherited truth, and slicing them away would silently widen the child to the deployment default. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. - **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, so they can never leak down the chain. ### What a blocked child experiences diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index ad1b465034..b9e978761a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -14,7 +14,7 @@ Status: implemented - **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 - **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——即纯函数导出 `sandboxOverrideOf`/`approvalOverrideOf`,以服务方法的形式暴露——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都无条件按自己的封闭词汇校验(即便自己做出的切换会遮蔽基线,损坏的会话头也会大声失败),遇到词汇之外的值即抛出异常。每一个旋钮消费方都经由同一条链解析——强制执行侧(`resolve()`、pty-local)与权限 preset(`current`/`set`)皆然——因此当选中更窄的 preset 时,继承了更宽基线的子 agent 得到的是真实的旋钮切换,而非静默的空操作。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入,`peerDependenciesMeta.optional`):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由种子边界自然得出。**fork 种子可能携带父级旧的切换事件;`overrideOf` 只折叠 `header.seedLength` 之后的事件,因此种子携带的历史已被委派时的基线所涵盖,而子 agent 自己做出的切换仍然优先于基线。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 +- **fork 陈旧种子的优先级由种子边界自然得出——仅限委派子 agent。**fork 种子可能携带父级旧的切换事件;当会话头基线存在时,`overrideOf` 只折叠 `header.seedLength` 之后的事件——基线是在委派时从父级的完整日志捕获的,因此种子携带的历史已被它所涵盖,而子 agent 自己做出的切换仍然优先于基线。没有基线时(顶层会话,或未捕获任何策略元数据的通用 `SessionStore.fork` 子会话),折叠覆盖完整日志:此时种子携带的切换本身就是回放所得的继承事实,把它们切掉会把子会话静默放宽到部署默认值。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 - **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 ### 被拦住的子 agent 会经历什么 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c6b2a68258..4d12247385 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,7 +1862,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:221`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:226`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 024994840e..5ed0806257 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -256,7 +256,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:236`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:241`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -778,10 +778,11 @@ Owns the deployment's permission presets and their write path. Requires a confin * override chains execution reads (own post-seed switches, else the * inherited header baseline, else the composition defaults), so a * delegated child's inherited knobs derive its real preset. A - * still-matching last OWN selection wins shared-bundle ties (a seed-carried - * selection is stale parent history, subsumed by the baseline); otherwise - * the first table match wins, or {@link CUSTOM_PRESET} when no entry - * matches. + * still-matching last selection wins shared-bundle ties, scoped like the + * knob chains: a delegation child (header baselines present) ignores + * seed-carried selections as stale parent history, while a generic fork + * child keeps them alongside its seed-carried knobs; otherwise the first + * table match wins, or {@link CUSTOM_PRESET} when no entry matches. * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0028a153fd..9db12a1e9e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -400,7 +400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'current(session: Session): string', - jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last OWN selection wins shared-bundle ties (a seed-carried\n * selection is stale parent history, subsumed by the baseline); otherwise\n * the first table match wins, or {@link CUSTOM_PRESET} when no entry\n * matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last selection wins shared-bundle ties, scoped like the\n * knob chains: a delegation child (header baselines present) ignores\n * seed-carried selections as stale parent history, while a generic fork\n * child keeps them alongside its seed-carried knobs; otherwise the first\n * table match wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, { signature: 'resolve(name: string): PresetSpec', diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index b93b3a3311..77822b4b58 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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: 2088574df7145b72c130bdc4f8f841517afb004b -README.zh.md: e44ab8949f7c128fec7bf1aa366b6ad0018ec7e0 +README.md: ca6ee67ab0cdb645bba135b3ca4f542844306857 +README.zh.md: a0c43235de97872ef734f0bef3cc6ed16930e232 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 2088574df7..ca6ee67ab0 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -19,7 +19,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. -- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `sandboxMode` delegation baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary). The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: with an inherited `sandboxMode` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary); without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so seed-carried switches remain the replayed inherited truth. The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index e44ab8949f..a0c43235de 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -19,7 +19,7 @@ - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 - `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `sandboxMode` 委派基线;读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界)。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:当存在继承的 `sandboxMode` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界);没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的切换仍是回放所得的继承事实。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 28a4234b26..93ba463d30 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -56,13 +56,17 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo /** * The session's complete sandbox-mode OVERRIDE chain — the one home every - * consumer (the policy service, the permission presets) resolves through: - * the fold of the session's OWN switches (events past the seed boundary — a - * fork seed's stale parent switch is subsumed by the delegation baseline - * captured after it), else the header's inherited baseline. Never the - * deployment default. The durable baseline is validated UNCONDITIONALLY — a - * corrupt or foreign header must fail loud on every read, not only when no - * own switch happens to shadow it. + * consumer (the policy service, the permission presets) resolves through. + * With a header baseline (a delegation child), the fold covers only the + * session's OWN switches past the seed boundary — the baseline was captured + * from the parent's FULL log at delegation, so any seed-carried switch is + * already subsumed by it, stale or not. Without a baseline (a top-level + * session, or a generic `SessionStore.fork` child that captured no policy + * meta), the fold covers the whole log: seeded switches ARE the replayed + * inherited truth, and slicing them away would silently widen the child to + * the deployment default. Never the deployment default itself. The durable + * baseline is validated UNCONDITIONALLY — a corrupt or foreign header must + * fail loud on every read, not only when no own switch happens to shadow it. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the deployment default. @@ -70,11 +74,12 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo */ export function sandboxOverrideOf(session: Session): SandboxMode | undefined { const baseline = session.header.sandboxMode - if (baseline !== undefined && !SANDBOX_MODES.includes(baseline as SandboxMode)) { + if (baseline === undefined) return effectiveSandboxMode(session.events) + if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) } const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) - return own ?? baseline as SandboxMode | undefined + return own ?? baseline as SandboxMode } /** diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 3d1f1ae47d..a0924c0d99 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -205,4 +205,17 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) }) + + it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { + const ctx = await mounted({ mode: 'workspace-write' }) + // The public fork path sets seedLength but captures no delegation + // baseline; the seed boundary must not discard the replayed policy state + // it exists to subsume — with nothing to subsume it, seeded switches ARE + // the child's inherited truth. + const child = inheritedSession('sess-generic-fork', { seedLength: 1 }) + setSandboxMode(child, 'read-only') + + expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') + expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') + }) }) diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 8d89472abd..616a92c3f3 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -143,10 +143,11 @@ export class PermissionService extends Service { * override chains execution reads (own post-seed switches, else the * inherited header baseline, else the composition defaults), so a * delegated child's inherited knobs derive its real preset. A - * still-matching last OWN selection wins shared-bundle ties (a seed-carried - * selection is stale parent history, subsumed by the baseline); otherwise - * the first table match wins, or {@link CUSTOM_PRESET} when no entry - * matches. + * still-matching last selection wins shared-bundle ties, scoped like the + * knob chains: a delegation child (header baselines present) ignores + * seed-carried selections as stale parent history, while a generic fork + * child keeps them alongside its seed-carried knobs; otherwise the first + * table match wins, or {@link CUSTOM_PRESET} when no entry matches. * @param session - the session whose preset to derive. * @returns the effective preset name, or `custom` when nothing matches. */ @@ -154,7 +155,8 @@ export class PermissionService extends Service { const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask' const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval - const folded = effectivePermissionPreset(session.events.slice(session.header.seedLength ?? 0)) + const delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined + const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index bf32de8dac..0d7efac766 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/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: f6eaf049a46bc2faa7e0b12809d3d8c63d7792d6 -README.zh.md: a286d13eb7c5a3e7893a8269c6d50175eff47046 +README.md: 978a397f3f48b4e20461c2486e0611313526abf9 +README.zh.md: 96bbb13c0fbe555faa3dcca44caf31227c505a41 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index f6eaf049a4..978a397f3f 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: the fold of the session's OWN switches (events past `SessionHeader.seedLength`), else the header's inherited `approvalPolicy` delegation baseline, validated against the closed vocabulary on read; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index a286d13eb7..96bbb13c0f 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:先折叠会话自己的切换(`SessionHeader.seedLength` 之后的事件),否则取会话头中继承的 `approvalPolicy` 委派基线,读取时按封闭词汇校验;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 2c44d674b5..2692bff997 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -141,12 +141,16 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv /** * The session's complete approval-policy OVERRIDE chain — the one home every * consumer (this service's policy tier, the permission presets) resolves - * through: the fold of the session's OWN switches (events past the seed - * boundary — a fork seed's stale parent switch is subsumed by the delegation - * baseline captured after it), else the header's inherited baseline. Never - * the configured default. The durable baseline is validated UNCONDITIONALLY — - * a corrupt or foreign header must fail loud on every read, not only when no - * own switch happens to shadow it. + * through. With a header baseline (a delegation child), the fold covers only + * the session's OWN switches past the seed boundary — the baseline was + * captured from the parent's FULL log at delegation, so any seed-carried + * switch is already subsumed by it. Without a baseline (a top-level session, + * or a generic `SessionStore.fork` child that captured no policy meta), the + * fold covers the whole log: seeded switches ARE the replayed inherited + * truth, and slicing them away would silently drop a forked `'never'`. Never + * the configured default itself. The durable baseline is validated + * UNCONDITIONALLY — a corrupt or foreign header must fail loud on every + * read, not only when no own switch happens to shadow it. * @param session - the session whose override chain to resolve. * @returns the effective override, or `undefined` for a session following * the configured default. @@ -154,11 +158,12 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv */ export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { const baseline = session.header.approvalPolicy - if (baseline !== undefined && !APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { + if (baseline === undefined) return effectiveApprovalPolicy(session.events) + if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) } const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) - return own ?? baseline as ApprovalPolicy | undefined + return own ?? baseline as ApprovalPolicy } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index f4ada96338..76a4afef5f 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -644,4 +644,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) }) + + it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { + const ctx = await mounted() + // The public fork path sets seedLength but captures no delegation + // baseline; with nothing to subsume them, seeded switches ARE the + // child's inherited truth — slicing would silently drop a forked 'never'. + const child = inheritedSession('sess-appr-generic-fork', { seedLength: 1 }) + setApprovalPolicy(child, 'never') + + expect(ctx.approval.overrideOf(child)).toBe('never') + }) }) From 9aaa4a871f1723aca26cf8f1bf20945e116dcf7d Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 23:31:01 +0800 Subject: [PATCH 13/61] policy: reject out-of-range seed boundaries; carry baselines through session-query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (ds-review-bot on #623): - overrideOf (both knobs) rejects a seedLength past the log end before slicing: a malformed durable boundary would otherwise empty the own-switch slice until the log outgrew it, letting a wide baseline shadow a REAL later tightening. Malformed durable metadata fails loud, never open. - The session-query derived index carries the two baseline fields end to end: schema columns on both session tables (SESSION_QUERY_SQLITE_SCHEMA _VERSION 6 — derived, rebuilds in place), inserts, header selects, the candidates CTE, rowHeader, sameHeader, and the cross-source assertSessionHeadersCompatible — so a search hit's header keeps the child's inherited confinement and conflicting live/persisted baselines reject. Red-first: out-of-range seedLength tests in both policy suites; baseline round-trip and live/persisted baseline-conflict tests in the session-query sqlite suite. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../sandbox-policy/src/session-mode.ts | 9 ++++- .../sandbox-policy/tests/policy.spec.ts | 11 ++++++ .../session-query-sqlite/src/index.ts | 26 ++++++++++---- .../session-query-sqlite/src/schema.ts | 6 +++- .../session-query-sqlite/tests/sqlite.spec.ts | 34 +++++++++++++++++++ .../session-query/src/sources.ts | 2 ++ packages/ui/user-approval/src/index.ts | 9 ++++- .../ui/user-approval/tests/approval.spec.ts | 8 +++++ 10 files changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d12247385..dae508be7b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1862,7 +1862,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:226`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:233`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5ed0806257..575d4e7648 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -256,7 +256,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:241`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:248`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 93ba463d30..c74def8e5e 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -78,7 +78,14 @@ export function sandboxOverrideOf(session: Session): SandboxMode | undefined { if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) } - const own = effectiveSandboxMode(session.events.slice(session.header.seedLength ?? 0)) + // A boundary past the log would make the own-switch slice empty until the + // log grows past it — a wide baseline would then shadow a REAL later + // tightening. Malformed durable metadata fails loud, never fails open. + const seedLength = session.header.seedLength ?? 0 + if (seedLength > session.events.length) { + throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) + } + const own = effectiveSandboxMode(session.events.slice(seedLength)) return own ?? baseline as SandboxMode } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index a0924c0d99..d69b5b0e2b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -218,4 +218,15 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') }) + + it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { + const ctx = await mounted() + // A malformed durable seedLength beyond the log would make the own-switch + // slice empty until the log grows past it — a wide baseline would then + // shadow a REAL later tightening. Fail loud at the durable boundary. + const child = inheritedSession('sess-inherit-oob', { sandboxMode: 'danger-full-access', seedLength: 100 }) + setSandboxMode(child, 'read-only') + + expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/) + }) }) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0c073ccea5..2f86e29919 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -146,6 +146,8 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null + sandbox_mode: string | null + approval_policy: string | null } interface SearchRow extends SessionHeaderRow { @@ -533,8 +535,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -543,6 +545,8 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.sandboxMode ?? null, + entry.header.approvalPolicy ?? null, revision, generation, ) @@ -569,8 +573,8 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -579,6 +583,8 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.parentSession ?? null, entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, + entry.header.sandboxMode ?? null, + entry.header.approvalPolicy ?? null, entry.fingerprint, persisted ? 1 : 0, generation, @@ -671,7 +677,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -681,7 +687,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -740,6 +746,8 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, + ps.sandbox_mode AS sandbox_mode, + ps.approval_policy AS approval_policy, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -762,6 +770,8 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, + ls.sandbox_mode AS sandbox_mode, + ls.approval_policy AS approval_policy, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -870,6 +880,8 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) + && a.sandboxMode === b.sandboxMode + && a.approvalPolicy === b.approvalPolicy } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -881,6 +893,8 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.sandbox_mode === null ? {} : { sandboxMode: row.sandbox_mode }, + ...row.approval_policy === null ? {} : { approvalPolicy: row.approval_policy }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..1bd832aa47 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -117,6 +117,8 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + sandbox_mode TEXT, + approval_policy TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -146,6 +148,8 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + sandbox_mode TEXT, + approval_policy TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 2fdd0b1e89..624cec4029 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -220,6 +220,40 @@ describe('SQLite session search', () => { .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) + it('round-trips the inherited policy baselines through search headers', async () => { + // A delegated child's header carries the sandbox/approval baselines; the + // derived index must return them — a consumer resuming from a search hit + // would otherwise rebuild a child without its inherited confinement. + const ctx = await liveContext({ path: ':memory:' }) + const session = ctx.sessions.create(SessionId('live-baseline'), { + meta: { cwd: '/work', createdAt: 10, sandboxMode: 'read-only', approvalPolicy: 'never' }, + }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'baseline needle' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + + const result = await ctx.sessionQuery.searchSessions({ query: 'needle' }) + expect(result.items[0]?.header).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) + const events = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle' }) + expect(events.session).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) + }) + + it('rejects live/persisted sources whose policy baselines conflict', async () => { + const shared = header('baseline-conflict', 10, { sandboxMode: 'read-only' }) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + ctx.sessions.create(shared.id, { + seed: messageEvents('live needle'), + meta: { createdAt: 10, sandboxMode: 'danger-full-access' }, + }) + + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + it('searches all surfaces by default and applies metadata before ranking', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const parent = SessionId('parent') diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts index f1bb64275f..2daccb6b28 100644 --- a/packages/session-query/session-query/src/sources.ts +++ b/packages/session-query/session-query/src/sources.ts @@ -17,6 +17,8 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade || a.parentSession !== b.parentSession || a.seedLength !== b.seedLength || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) + || a.sandboxMode !== b.sandboxMode + || a.approvalPolicy !== b.approvalPolicy ) { throw new SessionQueryError( `session source headers conflict for session "${a.id}"`, diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 2692bff997..06ea9ae9ef 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -162,7 +162,14 @@ export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) } - const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0)) + // A boundary past the log would make the own-switch slice empty until the + // log grows past it — a baseline would then shadow a REAL later switch. + // Malformed durable metadata fails loud, never fails open. + const seedLength = session.header.seedLength ?? 0 + if (seedLength > session.events.length) { + throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) + } + const own = effectiveApprovalPolicy(session.events.slice(seedLength)) return own ?? baseline as ApprovalPolicy } diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 76a4afef5f..431423f959 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -655,4 +655,12 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(ctx.approval.overrideOf(child)).toBe('never') }) + + it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-appr-oob', { approvalPolicy: 'ask', seedLength: 100 }) + setApprovalPolicy(child, 'never') + + expect(() => ctx.approval.overrideOf(child)).toThrow(/seedLength/) + }) }) From 290e1acc45ba7e9710eb507b999eb84ba8fb100d Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 23:56:41 +0800 Subject: [PATCH 14/61] policy: guard adoption baselines, resolve-time validation, and inherited-delta narration Review fixes (ds-review-bot on #623): - Persistence adoption compares the immutable policy baselines: onCreated's ownerless claim and adoptLivePrefix retain the STORED header, so a same-id live session with a conflicting baseline now rejects as a collision instead of appending under read-only and resuming under the stored danger-full-access. - resolve() resolves the session override BEFORE applying an explicit approved mode: the one-shot grant no longer bypasses the unconditional durable-header validation. - The approval narrator attributes positionally over the session's OWN events (past the seed boundary): a fork child whose baseline delta has no own override narrates 'inherited from the delegating session' instead of misattributing a stale seed-carried switch to the user or the operator. Red-first: baseline-conflict adoption in the shared coordinator contract (both backends), resolve-with-explicit-mode validation, and the fork-child narration attribution case. --- packages/sandbox/sandbox-policy/src/index.ts | 6 +++- .../sandbox-policy/tests/policy.spec.ts | 9 ++++++ .../session-persistence/src/coordinator.ts | 18 +++++++++++ .../tests/coordinator-contract.ts | 21 +++++++++++++ packages/ui/user-approval/README.i18n.yaml | 4 +-- packages/ui/user-approval/README.md | 4 +-- packages/ui/user-approval/README.zh.md | 4 +-- packages/ui/user-approval/src/index.ts | 24 ++++++++++----- .../ui/user-approval/tests/approval.spec.ts | 30 +++++++++++++++++++ 9 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 2dba47a643..dd4f2d6465 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -99,8 +99,12 @@ export class SandboxPolicyService extends Service { */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request + // Resolve the session override FIRST even when an explicit approved mode + // outranks it: the unconditional durable-header validation must hold on + // every resolution — a one-shot grant is not a validation bypass. + const override = session === undefined ? undefined : this.overrideOf(session) return { - mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, + mode: request.mode ?? override ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index d69b5b0e2b..b0362b6592 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -229,4 +229,13 @@ describe('delegation inheritance (overrideOf over the header baseline)', () => { expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/) }) + + it('resolve() validates the durable header even when an explicit approved mode is supplied', async () => { + const ctx = await mounted() + const child = inheritedSession('sess-resolve-invalid', { sandboxMode: 'yolo' }) + + // The explicit one-shot grant must not become a validation bypass: the + // unconditional durable-header contract holds on EVERY resolution. + expect(() => ctx.sandboxPolicy.resolve({ session: child, mode: 'workspace-write' })).toThrow(/sandboxMode/) + }) }) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index c252957db7..25ffad5f93 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -113,6 +113,22 @@ async function settledErrors(promises: Iterable>): Promise { if (tracked.meta.cwd !== session.header.cwd) { throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } + assertSamePolicyBaselines(id, tracked.meta, session.header) if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) } @@ -587,6 +604,7 @@ export class PersistenceCoordinator { if (meta.cwd !== session.header.cwd) { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } + assertSamePolicyBaselines(session.header.id, meta, session.header) this.assertVersion(meta) assertSupportedEvents(events, session.header.id) if (!seedCoversPrefix(seed, events)) { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 83ee8d774c..4bbb8aa471 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -679,6 +679,27 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // A stored artifact carrying a WIDE baseline. A same-id live session + // claiming a NARROW baseline must be rejected: adoption retains the + // stored header, so accepting the pair would let the session append + // under read-only now but resume under danger-full-access later. + await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' }) + await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog()) + const live = ctx.sessions.create(SessionId('baseline-conflict'), { + seed: oneTurnLog(), + meta: { cwd: WORK, sandboxMode: 'read-only' }, + }) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 0d7efac766..eff3ad6622 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/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: 978a397f3f48b4e20461c2486e0611313526abf9 -README.zh.md: 96bbb13c0fbe555faa3dcca44caf31227c505a41 +README.md: 251782ac19de54413e6e318d141f39f964900a32 +README.zh.md: 9fab4f5cb73bb893d7c55a2e65c43c96eb9dfa4c diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 978a397f3f..251782ac19 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). @@ -18,7 +18,7 @@ The tools pipeline routes `ask` decisions through this seam and fails closed whe #### What the model sees -Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).` or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. +Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).`, `The approval policy changed from "" to "" (inherited from the delegating session).`, or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. ##### Ask-policy prompt section diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index 96bbb13c0f..9fab4f5cb7 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知,并按其在会话自己的事件中的位置归因:如果会话自己的覆盖出现在自己最后一个 `request/header` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -18,7 +18,7 @@ #### 模型看到的内容 -在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 +在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).`、`The approval policy changed from "" to "" (inherited from the delegating session).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 ##### Ask 策略提示词段 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 06ea9ae9ef..3d4ed57416 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -276,17 +276,21 @@ export class ApprovalService extends Service { // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told // is in-memory with a log-derived fallback (the folded header's system - // text), so restarts lose nothing. Attribution is positional: an - // override event after the log's last `request/header` was a runtime - // switch by the user; otherwise the configured default moved under the - // session (operator/config). + // text), so restarts lose nothing. Attribution is positional over the + // session's OWN events (past the seed boundary — a seed-carried switch is + // stale parent history, never this session's runtime action): an own + // override after the last own `request/header` was a runtime switch by + // the user; no own override with the delta matching the inherited header + // baseline came from the delegating session; otherwise the configured + // default moved under the session (operator/config). const narrated = new WeakMap() ctx.on('agent/pre-step', (agent) => { const session = agent.session const events = session.events + const seedStart = Math.min(session.header.seedLength ?? 0, events.length) let overrideIndex = -1 let headerIndex = -1 - for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) { + for (let index = events.length - 1; index >= seedStart && (overrideIndex < 0 || headerIndex < 0); index -= 1) { const event = events[index] as (typeof events)[number] if (overrideIndex < 0 && event.type === 'approval/policy') { overrideIndex = index @@ -294,8 +298,8 @@ export class ApprovalService extends Service { headerIndex = index } } - // Same fold effectivePolicy performs — override is scanned here anyway - // for POSITIONAL attribution; the default lives once, in the method. + // Same fold effectivePolicy performs — the own override is scanned here + // anyway for POSITIONAL attribution; the default lives once, in the method. const current = this.effectivePolicy(session) const header = session.requestHeader() const told = narrated.get(session) ?? toldApprovalPolicy(header?.system) @@ -303,7 +307,11 @@ export class ApprovalService extends Service { // Cold start (nothing ever told) narrates nothing — the section about // to go out states the truth, and there is no delta to explain. if (told === undefined || told === current) return - const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config' + const cause = overrideIndex > headerIndex + ? 'changed by the user' + : overrideIndex < 0 && session.header.approvalPolicy === current + ? 'inherited from the delegating session' + : 'changed by the operator/config' agent.inject( [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }], { source: { kind: 'plugin', plugin: 'user-approval' } }, diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 431423f959..c3707b8121 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -517,6 +517,36 @@ describe('approval policy (the approval/policy fold)', () => { expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).']) }) + it('does not attribute a fork child\'s baseline delta to a stale seed-carried user switch', async () => { + // A fork child: the seed carries the parent's OLD 'ask' switch (event 0, + // inside seedLength) and the last request header told 'ask'; the header + // baseline captured at delegation is 'never'. The delta must not be + // attributed to "the user" — the seed switch is stale parent history, not + // this session's runtime action. + const ctx = new Context() + await ctx.plugin(ApprovalService) + const id = SessionId('sess-narr-fork-baseline') + const session = new Session(id, undefined, { + version: 0, + id, + createdAt: 0, + approvalPolicy: 'never', + seedLength: 2, + }) + setApprovalPolicy(session, 'ask') + session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: `persona\n${ASK_MARKER}` }, reason: 'initial' }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const injected: string[] = [] + const agent = { + id, + session, + inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') }, + } as unknown as Agent + + await preStep(ctx, agent) + expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) + }) + it('a pinned override survives a default change silently', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) From 49a4ebd4bc535e7f958d365530b6af82995ef3fa Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 00:16:54 +0800 Subject: [PATCH 15/61] persistence: include the seed boundary in the adoption policy-identity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (ds-review-bot on #623): matching baselines with differing seedLength still resolve different policies — overrideOf folds own switches past the boundary, so a stored seedLength: 1 marks event 0 as subsumed seed history while a live seedLength: 0 lets the same event tighten the session; adoption retained the stored header and a restart silently restored the wide baseline. When either side carries a baseline, the seed boundary is part of the policy identity and a mismatch rejects as an id collision. Red-first in the shared coordinator contract (both backends). --- .../session-persistence/src/coordinator.ts | 12 +++++++- .../tests/coordinator-contract.ts | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 25ffad5f93..457b19eaa6 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -117,7 +117,10 @@ async function settledErrors(promises: Iterable>): Promise Promise< } }) + it('a live session with a DIFFERENT seed boundary cannot adopt a stored prefix when a baseline exists', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Same wide baseline both sides, but the stored header says event 0 is + // seed-carried (seedLength 1) while the live header says it is the + // session's OWN (seedLength 0). overrideOf() resolves policy through + // that boundary: a read-only switch at event 0 tightens the live + // session, yet a restart resumes under the stored header and the wide + // baseline silently returns. The boundary is part of the policy + // identity whenever a baseline exists. + await ctx.sessionPersistence.create({ + ...meta('seed-boundary-conflict', WORK), + sandboxMode: 'danger-full-access', + seedLength: 1, + }) + await ctx.sessionPersistence.append(SessionId('seed-boundary-conflict'), oneTurnLog()) + const live = ctx.sessions.create(SessionId('seed-boundary-conflict'), { + seed: oneTurnLog(), + meta: { cwd: WORK, sandboxMode: 'danger-full-access' }, + }) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) From 68d59a54147a494f918dec3337564617f30c8b2d Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 12:54:54 +0800 Subject: [PATCH 16/61] test(snapshot): pin the parent-only override through the assembled headless app Review fix (ds-review-bot on #623): the ACP scenario runs at deployment strength (the automation protocol has no session-scoped switch), so the assembled-app path could not detect the delegation bypass itself. The new keyless subagent-inheritance headless scenario closes that on the semantic-checkpoint precedent: a seeded parent log carrying a real sandbox/mode: read-only switch under a workspace-write deployment default is resumed through the Loader-booted cli-demo app via a resume fixture plugin and delegates through the real subagent tool; the child's real write is denied by the real dsh-fs-sandbox fence (physical ENOENT assertion), its persisted header carries the inherited baseline, and both logs pin as expected outputs. Verified red: disabling the driver's capture makes the scenario fail on the disk assertion (the child writes under the deployment default). --- ...7-25-subagent-policy-inheritance.i18n.yaml | 6 +- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- .../subagent-inheritance.cordis.snapshot.yml | 69 ++++++++++ .../fixtures/subagent-inheritance-agent.ts | 25 ++++ .../parent-override/child.expected.jsonl | 24 ++++ .../parent-override/child.replay.jsonl | 18 +++ .../parent-override/parent.expected.jsonl | 28 ++++ .../parent-override/replay.override.json | 1 + .../tests/subagent-inheritance.snapshot.ts | 130 ++++++++++++++++++ 10 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 examples/headless-agent/subagent-inheritance.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts create mode 100644 examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl create mode 100644 examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl create mode 100644 examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl create mode 100644 examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json create mode 100644 examples/headless-agent/tests/subagent-inheritance.snapshot.ts diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 7084110b8d..8e7928f278 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-subagent-policy-inheritance.md: ea8e4626a1bd2ac31c2a7ce207bf9ba73e2e32fc -2026-07-25-subagent-policy-inheritance.zh.md: b9e978761af7de6ecf9a2e6ec167e2bb512c9ebf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +2026-07-25-subagent-policy-inheritance.md: fcc91310399dd0bbaae3acc444fbf790ae4d0b3c +2026-07-25-subagent-policy-inheritance.zh.md: d06948dc34213e87300bf46ee5f1fc43d8f48fc7 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index ea8e4626a1..fcc9131039 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -34,5 +34,5 @@ A confined child that hits the wall gets the ordinary denial marker; an escalati - A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). - The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. - Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. -- Snapshot coverage runs at deployment strength only: the recorded `subagent-sandbox-inheritance` scenario proves a delegated child confined under a read-only DEPLOYMENT policy through the assembled ACP app, but the automation-only protocol has no session-scoped switch, so a parent-ONLY override (wider default, tightened parent, denied child) is not expressible there. When picked up, the shape is the headless suite's semantic-checkpoint precedent: seed a persisted parent log carrying a real `sandbox/mode` switch, resume it through the Loader-booted cli-demo app via a resume fixture plugin, and let it delegate — a scenario-harness addition (the headless suite has no subagent+sandbox overlay yet), not a new mechanism. +- Assembled-app snapshots pin both strengths. The recorded `subagent-sandbox-inheritance` ACP scenario proves a delegated child confined under a read-only DEPLOYMENT policy (the automation-only protocol has no session-scoped switch). The keyless `subagent-inheritance` headless scenario pins the parent-ONLY override on the semantic-checkpoint precedent: a seeded parent log carrying a real `sandbox/mode: read-only` switch under a workspace-write deployment default is resumed through the Loader-booted cli-demo app via a resume fixture plugin and delegates; the child's real write is denied by the real fence, its persisted header carries the inherited baseline, and disabling the driver's capture makes the scenario fail on the physical disk assertion — the assembled-app red/green anchor for the delegation bypass. - `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index b9e978761a..d06948dc34 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -34,5 +34,5 @@ Status: implemented - 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 - 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 - 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 -- 快照覆盖只运行在部署强度上:已录制的 `subagent-sandbox-inheritance` 场景经由组装后的 ACP 应用,证明了一个被委派的子 agent 被约束在只读的部署级策略之下,但这个仅面向自动化的协议没有会话作用域的切换,因此仅父级的覆盖项(更宽的默认值、收紧的父级、被拒的子 agent)在该协议上无法表达。待接手时,其形态沿用 headless 套件的语义检查点先例:预置一份携带真实 `sandbox/mode` 切换的已持久化父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它,再让它发起委派——这是一次场景 harness 的扩充(headless 套件尚无 subagent+沙箱 overlay),而非新机制。 +- 组装后应用的快照钉住两种强度。已录制的 `subagent-sandbox-inheritance` ACP 场景证明了一个被委派的子 agent 被约束在只读的部署级策略之下(这个仅面向自动化的协议没有会话作用域的切换)。无密钥的 `subagent-inheritance` headless 场景则沿语义检查点先例钉住仅父级的覆盖项:在 workspace-write 的部署默认值之下,预置一份携带真实 `sandbox/mode: read-only` 切换的父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它并发起委派;子 agent 的真实写入被真实围栏拒绝,其持久化的会话头携带继承来的基线,而禁用驱动器的捕获会让该场景在物理落盘断言上失败——这就是委派旁路在组装后应用层面的红/绿锚点。 - `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 diff --git a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..fb614a5372 --- /dev/null +++ b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml @@ -0,0 +1,69 @@ +# Keyless real-Loader composition for the parent-only override inheritance +# snapshot. The deployment default stays WIDE (workspace-write) while the +# seeded parent session carries a session-scoped read-only override; the +# resumed parent delegates, and only the inheritance capture can confine the +# child — remove it and the child writes successfully under the deployment +# default, so this scenario is the assembled-app red/green anchor for the +# delegation bypass. + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +# file/override/childFiles all default to their DSH_SNAPSHOT_* env vars. +- id: replay + name: '@deepseek-ai/dsh-llm-replay' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# The confining filesystem stack: the wide deployment default lives on the +# shared policy home; the seeded parent's read-only override must beat it +# INSIDE the child for the scenario to deny. +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: workspace-write + workspaceRoot: !!js process.cwd() + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: agent + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: [] + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 1 + +# Await the persisted resume before the headless driver inspects root agents. +- id: resumed-agent + name: './tests/fixtures/subagent-inheritance-agent.ts' diff --git a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts new file mode 100644 index 0000000000..bd8a7aa2f7 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts @@ -0,0 +1,25 @@ +/** + * Loader fixture that resumes the seeded read-only parent before CLI dispatch. + * @module subagent-inheritance-agent + */ + +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Fixture plugin name. */ +export const name = 'subagent-inheritance-agent' +/** Services that must exist before the fixture resumes its agent. */ +export const inject = ['agents', 'agentLoop', 'sessionPersistence'] + +/** + * Resume the seeded session and bind its exact handle to this fixture's lifetime. + * @param ctx - settled agent and persistence services from the Loader tree. + * @returns after the resumed agent is published. + */ +export async function apply(ctx: Context): Promise { + const handle = await ctx.agents.resume({ + resumeSessionId: 'subagent-inheritance-parent' as SessionId, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }) + ctx.effect(() => () => handle.dispose(), 'subagent-inheritance-agent.handle') +} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl new file mode 100644 index 0000000000..87a1235e02 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -0,0 +1,24 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1,"sandboxMode":"read-only"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true,"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl new file mode 100644 index 0000000000..99947a3797 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl @@ -0,0 +1,18 @@ +{"type": "session", "version": 0, "id": "subagent-inheritance-child", "createdAt": 2, "delegationDepth": 1} +{"type":"turn/start","seq":0,"time":1,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":2,"data":{"content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":3,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":5,"time":6,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":7,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":7,"time":8,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"step/end","seq":8,"time":9,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":9,"time":10,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":10,"time":11,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":12,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":12,"time":13,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":13,"time":14,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":15,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"step/end","seq":15,"time":16,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":16,"time":17,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl new file mode 100644 index 0000000000..f28c710987 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}} +{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":2,"step":2,"content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json new file mode 100644 index 0000000000..1c0fe9ffad --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json @@ -0,0 +1 @@ +[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "delegate-write", "name": "subagent", "argumentsDelta": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "delegate-write", "name": "subagent", "arguments": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The delegated child was denied by the sandbox. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The delegated child was denied by the sandbox. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts new file mode 100644 index 0000000000..92ef87b8d8 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -0,0 +1,130 @@ +/** + * Keyless assembled-app snapshot for parent-only policy inheritance: the + * deployment default stays WIDE (workspace-write on the shared policy home) + * while the seeded parent session carries a session-scoped `sandbox/mode: + * read-only` override; the Loader-booted headless app resumes it, the parent + * delegates through the real subagent tool, and the child's real `write` + * hits the real `dsh-fs-sandbox` fence. Only the delegation-inheritance + * capture can confine the child here — remove it and the child inherits + * nothing, writes `inherited.txt` successfully under the deployment default, + * and every assertion below fails. This is the assembled-app red/green + * anchor the ACP scenario cannot express (the automation protocol has no + * session-scoped switch). + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url)) +const replayOverride = join(fixtureDir, 'replay.override.json') +const childReplay = join(fixtureDir, 'child.replay.jsonl') +const parentExpected = join(fixtureDir, 'parent.expected.jsonl') +const childExpected = join(fixtureDir, 'child.expected.jsonl') +const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const sessionId = SessionId('subagent-inheritance-parent') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const task = 'Delegate the write probe to a subagent.' + +/** + * Seed the parent: a completed turn whose ONLY policy fact is a session-scoped + * `sandbox/mode: read-only` switch — the deployment default stays wider, so + * the child's confinement below can come from inheritance alone. + */ +async function seedReadOnlyParent(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + } finally { + await ctx.fiber.dispose() + } +} + +describe('parent-only override inheritance snapshot', () => { + it('confines a delegated child through the assembled headless app', async () => { + let cwd = '' + const result = await runLoaderSmoke({ + label: 'subagent inheritance headless stream-json snapshot', + tempDirPrefix: 'dsh-subagent-inherit-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + tsconfigPath, + env: { + // The primary fixture path must exist for llm-replay's config guard; + // the override sidecar fully replaces the derived parent script. + DSH_SNAPSHOT_FILE: replayOverride, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + DSH_SNAPSHOT_CHILD_FILES: childReplay, + }, + prepare: async (runCwd) => { + cwd = runCwd + await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd) + }, + inspect: async (runCwd) => { + // THE physical fact: the child's write never reached the disk. Under + // the deployment default (workspace-write) alone it would succeed. + await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + + // Collect both persisted logs (parent resumed turn + child run). + const sessionsDir = join(runCwd, '.sessions') + const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) + const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) + const headerOf = (content: string): Record => + JSON.parse(content.split('\n')[0] ?? '{}') as Record + const parent = logs.find(content => content.includes('"subagent-inheritance-parent"')) + const child = logs.find(content => typeof headerOf(content).parentSession === 'string') + if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') + + // The inherited baseline is the child's durable header record. + expect(headerOf(child).sandboxMode).toBe('read-only') + + const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd } + const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) + const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context)) + if (refreshing) { + await writeFile(parentExpected, normalizedParent) + await writeFile(childExpected, normalizedChild) + } + expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) + expect(normalizedChild).toBe(await readFile(childExpected, 'utf8')) + // The child's real write was denied by the real fence. + expect(normalizedChild).toContain('file access denied under read-only mode') + }, + }) + + expect(result.stderr).toBe('') + const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + expect(records.at(-1)).toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'The delegated child was denied by the sandbox. PARENT_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) From 10bb6dc4feff7b523d7474df877d34898e0e4046 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 13:45:09 +0800 Subject: [PATCH 17/61] docs(policy): align every precedence statement with the override chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (ds-review-bot on #623): the READMEs and JSDoc still stated the pre-baseline formulas — resolve() outranking 'the session's last sandbox/mode event', 'effective = explicit grant ?? fold(events) ?? deployment default', and the approval README's 'last approval/policy event' opener — which contradict the shipped semantics for a delegated fork whose seed tail differs from its header baseline. Every statement now names the override chain (own post-seed switches ?? inherited header baseline): both READMEs (both languages), resolve()'s JSDoc, the session-mode module and event-declaration docs, the raw folds re-scoped as building blocks, and the regenerated catalogs. --- docs/cordis-catalog/services.md | 11 ++--- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../sandbox/sandbox-policy/README.i18n.yaml | 6 +-- packages/sandbox/sandbox-policy/README.md | 6 +-- packages/sandbox/sandbox-policy/README.zh.md | 6 +-- packages/sandbox/sandbox-policy/src/index.ts | 9 ++-- .../sandbox-policy/src/session-mode.ts | 44 +++++++++++-------- packages/ui/user-approval/README.i18n.yaml | 6 +-- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/README.zh.md | 2 +- packages/ui/user-approval/src/index.ts | 25 ++++++----- 11 files changed, 65 insertions(+), 54 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cb94678776..8c1a7283c0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -256,7 +256,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:248`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:251`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -957,10 +957,11 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo ```ts cordis-catalog /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's last `sandbox/mode` event, which outranks the - * deployment default. A session cwd is its workspace-write boundary; the - * configured root is the fallback for agentless calls and sessions without a - * cwd. + * mode outranks the session's override chain ({@link overrideOf}: own + * post-seed switches, else the inherited header baseline), which outranks + * the deployment default. A session cwd is its workspace-write boundary; + * the configured root is the fallback for agentless calls and sessions + * without a cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 69b30d2421..dca9c8e4a6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -488,7 +488,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', - jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', + jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s override chain ({@link overrideOf}: own\n * post-seed switches, else the inherited header baseline), which outranks\n * the deployment default. A session cwd is its workspace-write boundary;\n * the configured root is the fallback for agentless calls and sessions\n * without a cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 77822b4b58..a06a489cfb 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: ca6ee67ab0cdb645bba135b3ca4f542844306857 -README.zh.md: a0c43235de97872ef734f0bef3cc6ed16930e232 +# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md +README.md: be1125b1ec30e1db10223fe1023cc5dd7a57a3a7 +README.zh.md: 3ef9e5310570b1f2597fde99ff5cfcebb14096c1 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index ca6ee67ab0..be1125b1ec 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -15,9 +15,9 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de ## Surface -- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. +- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's override chain (`overrideOf`, below), which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. -- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. +- `effectiveSandboxMode(events)` — the pure fold of a slice of `sandbox/mode` events (the last switch wins, or `undefined`), the building block `sandboxOverrideOf` composes with the seed boundary and the header baseline. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: with an inherited `sandboxMode` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary); without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so seed-carried switches remain the replayed inherited truth. The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. @@ -26,7 +26,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve ## The per-session store -A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. +A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? override chain ?? deployment default`, where the override chain is `sandboxOverrideOf`'s fold of the session's OWN post-seed switches, else the inherited header baseline — so an override survives restart by replay, a delegation child starts under its parent's captured policy, and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. ## Model Experience diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index a0c43235de..3ef9e53105 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -15,9 +15,9 @@ ## 表层 -- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 +- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话的覆盖链(见下文 `overrideOf`),后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 -- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 +- `effectiveSandboxMode(events)`:对一段 `sandbox/mode` 事件切片的纯折叠(最后一次切换胜出,没有则为 `undefined`),是 `sandboxOverrideOf` 与种子边界和会话头基线进行组合时所用的基础构件。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 - `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:当存在继承的 `sandboxMode` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界);没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的切换仍是回放所得的继承事实。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 @@ -26,7 +26,7 @@ ## 逐会话 store -运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? override chain ?? deployment default`,其中覆盖链(override chain)是 `sandboxOverrideOf` 折叠会话自己在种子之后的切换所得,否则取继承的会话头基线——因此覆盖会通过回放跨重启保留,委派子 agent 会在其父级捕获的策略下启动,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index dd4f2d6465..dd35c303af 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -90,10 +90,11 @@ export class SandboxPolicyService extends Service { /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's last `sandbox/mode` event, which outranks the - * deployment default. A session cwd is its workspace-write boundary; the - * configured root is the fallback for agentless calls and sessions without a - * cwd. + * mode outranks the session's override chain ({@link overrideOf}: own + * post-seed switches, else the inherited header baseline), which outranks + * the deployment default. A session cwd is its workspace-write boundary; + * the configured root is the fallback for agentless calls and sessions + * without a cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index c74def8e5e..d2f018818a 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -1,15 +1,19 @@ /** - * Per-session sandbox-mode override: the session log as the store. A runtime - * switch (a UI policy control or test scenario) is recorded as one - * `sandbox/mode` event on the session it applies to; - * `effective = fold(events) ?? the deployment default`, so an override - * survives restart by replay, two sessions can never see each other's state, - * and there is no external config store. The event is log-only (the - * `approval/*` precedent): the model learns the mode from the boundary - * markers in the enforcing tools, never from the event itself. EXECUTION - * honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode - * together with the calling session's workspace root onto each capability - * call, weakest-precedence beneath an escalation grant. + * Per-session sandbox-mode override: the session log as the store, layered + * over the header's delegation baseline. A runtime switch (a UI policy + * control or test scenario) is recorded as one `sandbox/mode` event on the + * session it applies to; `effective = override chain ?? the deployment + * default`, where the override chain ({@link sandboxOverrideOf}) is the fold + * of the session's OWN post-seed switches, else the inherited + * `SessionHeader.sandboxMode` baseline. An override survives restart by + * replay, a delegation child starts under its parent's captured policy, two + * sessions can never see each other's state, and there is no external config + * store. The event is log-only (the `approval/*` precedent): the model + * learns the mode from the boundary markers in the enforcing tools, never + * from the event itself. EXECUTION honors the chain through + * `ctx.sandboxPolicy.resolve()` — it stamps the mode together with the + * calling session's workspace root onto each capability call, + * weakest-precedence beneath an escalation grant. * * The override is policy state shared by every enforcing family (bash and * filesystem alike), so it lives here in the policy package rather than in any @@ -26,10 +30,11 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}); who asked for it is derivable - * from position (an event after the log's last `request/header*` was a - * runtime switch by the user; see the tool layer's narrator). + * never in the model transcript. The last such OWN (post-seed) event is + * the session's override ({@link sandboxOverrideOf}); who asked for it is + * derivable from position (an event after the log's last + * `request/header*` was a runtime switch by the user; see the tool + * layer's narrator). */ 'sandbox/mode': { mode: SandboxMode } } @@ -39,10 +44,11 @@ declare module '@deepseek-ai/dsh-session' { export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] /** - * The session's sandbox-mode override: the last `sandbox/mode` event in the - * log, or undefined when the session never switched (callers apply the - * deployment default). The pure fold — resume needs no catch-up machinery - * because replaying the log IS the state. + * The pure fold of a slice of `sandbox/mode` events: the last switch wins, + * or undefined without one. The building block {@link sandboxOverrideOf} + * composes with the seed boundary and the header baseline — consumers + * resolving a SESSION's policy go through that chain, not this raw 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 mode of the last switch event, or undefined without one. */ diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index eff3ad6622..0b80c98b63 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 251782ac19de54413e6e318d141f39f964900a32 -README.zh.md: 9fab4f5cb73bb893d7c55a2e65c43c96eb9dfa4c +# pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md +README.md: c29ada50950cb6b40065a09bac1becbf4afb0ce4 +README.zh.md: c119f2009298edfaa101b10c7562d0c36324ec5d diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 251782ac19..c29ada5095 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the session's override chain (`approvalOverrideOf`, below), falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index 9fab4f5cb7..c119f20092 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知,并按其在会话自己的事件中的位置归因:如果会话自己的覆盖出现在自己最后一个 `request/header` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取会话的覆盖链(见下文 `approvalOverrideOf`),并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知,并按其在会话自己的事件中的位置归因:如果会话自己的覆盖出现在自己最后一个 `request/header` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 3d4ed57416..fb5503f3be 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -59,10 +59,11 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The LAST such - * event is the session's override ({@link effectiveApprovalPolicy}); - * who asked for it is derivable from position (an event after the log's - * last `request/header` was a runtime switch by the user). + * from the prompt section and the narrator's notices). The last such OWN + * (post-seed) event is the session's override + * ({@link approvalOverrideOf}); who asked for it is derivable from + * position (an own event after the log's last own `request/header` was a + * runtime switch by the user). */ 'approval/policy': { policy: ApprovalPolicy } } @@ -123,10 +124,12 @@ function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefi } /** - * The session's approval-policy override: the last `approval/policy` 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. + * The pure fold of a slice of `approval/policy` events: the last switch + * wins, or undefined without one. The building block + * {@link approvalOverrideOf} composes with the seed boundary and the header + * baseline — consumers resolving a SESSION's policy go through that chain, + * not this raw 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 policy of the last switch event, or undefined without one. */ @@ -359,9 +362,9 @@ export class ApprovalService extends Service { } /** - * The session's effective policy: its own `approval/policy` fold, else the - * configured default (the schema already defaulted an omitted policy to - * `'ask'`; the `??` only narrows the optional-input TYPE). + * The session's effective policy: its override chain ({@link overrideOf}), + * else the configured default (the schema already defaulted an omitted + * policy to `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. * @returns the policy every ask for this session resolves under right now. */ From 2f59db73be49eff29a2e0af99f5c7e2355215219 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 13:46:33 +0800 Subject: [PATCH 18/61] docs: regenerate catalogs for the override-chain JSDoc updates --- docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5a6675f556..4a1d879d34 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1869,7 +1869,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:233`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:236`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index becb8ffb75..f0ef1bd013 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -129,15 +129,16 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The LAST such - * event is the session's override ({@link effectiveApprovalPolicy}); - * who asked for it is derivable from position (an event after the log's - * last `request/header` was a runtime switch by the user). + * from the prompt section and the narrator's notices). The last such OWN + * (post-seed) event is the session's override + * ({@link approvalOverrideOf}); who asked for it is derivable from + * position (an own event after the log's last own `request/header` was a + * runtime switch by the user). */ 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -353,15 +354,16 @@ Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/ /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}); who asked for it is derivable - * from position (an event after the log's last `request/header*` was a - * runtime switch by the user; see the tool layer's narrator). + * never in the model transcript. The last such OWN (post-seed) event is + * the session's override ({@link sandboxOverrideOf}); who asked for it is + * derivable from position (an event after the log's last + * `request/header*` was a runtime switch by the user; see the tool + * layer's narrator). */ 'sandbox/mode': { mode: SandboxMode } ``` -Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/sandbox/sandbox-policy/src/session-mode.ts) +Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:39`](../packages/sandbox/sandbox-policy/src/session-mode.ts) ### `session/*` From 8c106017d959eb8c001eb8e8c2c29fd5f49d2727 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 15:30:39 +0800 Subject: [PATCH 19/61] docs(notes): update the sandbox note's per-session formula to the header-baseline chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (ds-review-bot on #623): the per-session-modes section still described first-turn stamping — superseded by the header baseline — and its formula lacked the middle rung. The formula now reads own post-seed knob events ?? inherited SessionHeader baseline ?? composition default, and the paragraph describes the synchronous capture into the creation-time header with no first-turn timing window. Chinese counterpart updated in the same commit. --- .../notes/implemented/feature/2026-07-06-sandbox.i18n.yaml | 6 +++--- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 6 ++++-- .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md | 6 ++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index f2395fc8b4..6f0610036e 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-06-sandbox.md: 4409b6a32809c8ff7a02c0684f449143c0891caa -2026-07-06-sandbox.zh.md: 7e5564f17ab791662f5a40ed625b937725293333 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md +2026-07-06-sandbox.md: cd36ae62b60fd61d55d63469ad50234363dbcf86 +2026-07-06-sandbox.zh.md: 3930c654d0970c294e135d2e5ec9d530249a902d diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 4409b6a328..cd36ae62b6 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -89,10 +89,12 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — #### Per-session modes: the session log as the store ``` -effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default +effective(session) = findLast(the session's OWN post-seed knob events)?.value + ?? the inherited SessionHeader baseline + ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver stamps a delegating parent's effective override onto each child session inside the child's first turn, so a tightened parent binds spawn children, fork children, and grandchildren ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)). +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log; the middle rung is the delegation baseline a subagent child's header carries. Restart immunity (resuming a session replays its log and restores its header, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver captures a delegating parent's effective override synchronously at delegation and carries it into each child's creation-time `SessionHeader` (`sandboxMode`/`approvalPolicy`), so a tightened parent binds spawn children, fork children, and grandchildren with no first-turn timing window ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 7e5564f17a..3930c654d0 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -89,10 +89,12 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### 按会话模式:会话日志即存储 ``` -effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default +effective(session) = findLast(the session's OWN post-seed knob events)?.value + ?? the inherited SessionHeader baseline + ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。隔离并不使委派成为逃生通道:进程内 subagent 驱动器会在子 agent 的首个轮次内,把发起委派的父级的有效覆盖盖章写入每个子会话,因此收紧后的父级会约束 spawn 子 agent、fork 子 agent 与孙代 agent([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件;中间层是 subagent 子 agent 的会话头所携带的委派基线。重启免疫(恢复会话时回放其日志并还原其会话头,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。隔离并不使委派成为逃生通道:进程内 subagent 驱动器在委派时同步捕获发起委派的父级的有效覆盖,并将其带入每个子 agent 创建时的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),因此收紧后的父级会约束 spawn 子 agent、fork 子 agent 与孙代 agent,且不存在任何第一轮次的时序窗口([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): From 6a3db40e52285e793f7fd0e54a39ddcbb328b21a Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 17:52:40 +0800 Subject: [PATCH 20/61] test(ci): register the inheritance fixture with knip; close two coverage gaps - knip: the subagent-inheritance resume fixture is a Loader-loaded entry (the semantic-checkpoint-agent precedent), invisible to import analysis. - coordinator coverage: a quiet-signal observation read pins the success arm of observeQueuedAbort, and an approval-only baseline pins the other arm of the seed-boundary guard's baseline disjunction. --- knip.json | 1 + .../session-persistence/tests/contract.ts | 16 +++++++++++++++ .../tests/coordinator-contract.ts | 20 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/knip.json b/knip.json index 110abb3a2b..adb7c65a8f 100644 --- a/knip.json +++ b/knip.json @@ -29,6 +29,7 @@ "entry": [ "headless-agent/tests/fixtures/cli-mock-llm.ts", "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", + "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 4508e0ccaa..61ddc12dfb 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -268,6 +268,22 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + // The abort observer must not swallow an ordinary success: a signal + // that stays quiet leaves the queued operation's resolution intact. + const m = meta('signal-quiet-inspect', '/work') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) + const controller = new AbortController() + await expect(persistence.inspect(m.id, controller.signal)) + .resolves.toMatchObject({ meta: { id: m.id } }) + } finally { + await dispose() + } + }) + it('rejects pre-aborted observation reads with the exact cancellation reason', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index a82ef525ec..5c6d5bcf85 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -728,6 +728,26 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('an approval-only baseline also pins the seed boundary (the other baseline arm)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // The boundary guard triggers off EITHER baseline: a stored header + // with only approvalPolicy (no sandboxMode, no seedLength) must still + // reject a live twin whose boundary differs. + await ctx.sessionPersistence.create({ ...meta('approval-boundary-conflict', WORK), approvalPolicy: 'never' }) + await ctx.sessionPersistence.append(SessionId('approval-boundary-conflict'), oneTurnLog()) + const live = ctx.sessions.create(SessionId('approval-boundary-conflict'), { + seed: oneTurnLog(), + meta: { cwd: WORK, approvalPolicy: 'never', seedLength: 2 }, + }) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) From bb1625678289d240acf5e8d68e06955a3f99a9de Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 18:57:03 +0800 Subject: [PATCH 21/61] refactor(session-query): share the header column bindings between both inserts The baseline columns pushed the two INSERT binding lists past the clone threshold; one _headerBindings helper now owns the shared header prefix (the duplication gate's finding). --- .../session-query-sqlite/src/index.ts | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 2f86e29919..ce89921ade 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -526,6 +526,23 @@ export class SessionQuerySqlite extends SessionQueryService { } } + /** The shared header column bindings both session tables lead with. */ + private static _headerBindings( + header: SessionHeader, + ): [string, number, number, string | null, string | null, number | null, number | null, string | null, string | null] { + return [ + header.id, + header.version, + header.createdAt, + header.cwd ?? null, + header.parentSession ?? null, + header.seedLength ?? null, + header.delegationDepth ?? null, + header.sandboxMode ?? null, + header.approvalPolicy ?? null, + ] + } + private _replacePersistedSession( entry: ObservedSession, revision: SessionPersistenceRevision, @@ -537,19 +554,7 @@ export class SessionQuerySqlite extends SessionQueryService { INSERT INTO persisted_sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, - entry.header.sandboxMode ?? null, - entry.header.approvalPolicy ?? null, - revision, - generation, - ) + `).run(...SessionQuerySqlite._headerBindings(entry.header), revision, generation) const insert = db.prepare(` INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -575,20 +580,7 @@ export class SessionQuerySqlite extends SessionQueryService { INSERT INTO temp.live_sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, - entry.header.sandboxMode ?? null, - entry.header.approvalPolicy ?? null, - entry.fingerprint, - persisted ? 1 : 0, - generation, - ) + `).run(...SessionQuerySqlite._headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation) const insert = db.prepare(` INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) From 9c78a7df33eace02b29e5539f8c5df107b18f8c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:26:03 +0800 Subject: [PATCH 22/61] test(snapshot): share identical header sidecars --- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 6 +- .../testing/2026-06-19-acp-snapshot-tests.md | 5 +- .../2026-06-19-acp-snapshot-tests.zh.md | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 28 +- .../both-mode-turn/system-prompt.expected.md | 401 --------------- .../system-prompt.expected.md | 401 --------------- .../tool-schemas.expected.json | 26 - .../tool-schemas.expected.json | 473 ------------------ .../skill-load/system-prompt.expected.md | 25 - .../skill-load/tool-schemas.expected.json | 473 ------------------ .../tool-schemas.expected.json | 473 ------------------ .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 6 +- packages/support/acp-snapshot/README.zh.md | 6 +- packages/support/acp-snapshot/src/suite.ts | 240 +++++++-- .../fixtures/suite/shared-pin/behavior.json | 12 + .../fixtures/suite/shared-pin/input.json | 1 + .../fixtures/suite/shared-pin/session.jsonl | 4 + .../suite/shared-pin/stdout.expected.jsonl | 4 + .../support/acp-snapshot/tests/suite.spec.ts | 165 ++++++ 20 files changed, 433 insertions(+), 2325 deletions(-) delete mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 13d4b4e495..48850b2a53 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-19-acp-snapshot-tests.md: 430441e633af1e487f19272900360a8ed2f595c9 -2026-06-19-acp-snapshot-tests.zh.md: 243e431567b45d69e1fe16dda5d07ec058403b7c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db +2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index 430441e633..ba46682b30 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -53,7 +53,7 @@ Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct: 1. The **stdout transcript** — the framed ACP JSON-RPC responses and committed-message updates an automation client receives. It catches regressions in the transport contract and is compared against a committed `stdout.expected.jsonl`. -2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. +2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt and tool bulk are scrubbed; one scenario per header class pins the remaining header sequence. The pin owns readable prompt and tool-schema sidecars by default, or names another pin as either source when the complete sequence is identical, so each distinct sidecar version is committed once. Fixture guards reject duplicate sidecar content, and record/refresh rejects shared claimants that generate different bytes. The original header-pinning rationale is preserved in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. @@ -76,9 +76,10 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log - **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output. - **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. - **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. +- **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content. ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 243e431567..58195c4788 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -53,7 +53,7 @@ Status: implemented 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: 1. **stdout transcript**——自动化客户端收到的、经过 framing 的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。 -2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。 +2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema sidecar;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源,因此每个不同的 sidecar 版本只提交一次。fixture 保护会拒绝重复的 sidecar 内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其 sidecar 派生模型行为。 两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。 @@ -76,9 +76,10 @@ Status: implemented - **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 +- **在每个类别 pin 旁复制两个请求头 sidecar**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9987679607..6ac82baf3a 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -73,8 +73,8 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['m const SCENARIOS: Scenario[] = [ { name: 'handshake', hasModelTurn: false, recorded: false }, { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, - // text-turn is the pinned-header scenario: the minimal single text turn. - // Its prompt and tool-schema sidecars pin the composed header. + // text-turn is the default header pin and owns the prompt and tool-schema + // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'session-title-after-turn', @@ -116,7 +116,15 @@ const SCENARIOS: Scenario[] = [ }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, { name: 'todo-write', hasModelTurn: true, recorded: true }, - { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, + { + name: 'skill-load', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'skill', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, // web_fetch markdown rendering end to end: the overlay's loopback fixture // server supplies deterministic HTML (entities, a GFM table, nesting), the @@ -164,6 +172,7 @@ const SCENARIOS: Scenario[] = [ overridden: true, pinsHeader: true, headerClass: 'workspace-context', + toolSchemasSource: 'text-turn', configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, @@ -236,9 +245,19 @@ const SCENARIOS: Scenario[] = [ recorded: true, pinsHeader: true, headerClass: 'code-workspace-context', + systemPromptSource: 'code-mode-turn', + toolSchemasSource: 'code-mode-turn', configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG, }, - { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, + { + name: 'both-mode-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'both', + systemPromptSource: 'code-mode-turn', + configPath: BOTH_MODE_CONFIG, + }, // Machine permission scenarios use an explicit deployment policy; there is // no session-scoped UI picker on the automation protocol. { @@ -247,6 +266,7 @@ const SCENARIOS: Scenario[] = [ recorded: true, pinsHeader: true, headerClass: 'sandbox', + toolSchemasSource: 'text-turn', env: { DSH_PERMISSION_MODE: 'workspace-write' }, }, { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md deleted file mode 100644 index 0cee2a6517..0000000000 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ /dev/null @@ -1,401 +0,0 @@ -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}}. - -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. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -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. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. - -## Writing code for run_code - -Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. - -The available tools: - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } - -interface ToolArgsMap { - /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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: { - /** The bash command to execute. */ - command: string; - /** 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". */ - description: string; - /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ - timeoutMs?: number; - /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ - workdir?: string; - /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). 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; - } & Record; - /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal: { - /** The concrete completion objective inferred from the direct human request. */ - objective: string; - /** Optional positive safe-integer limit on automatic continuation rounds. */ - max_goal_rounds?: number; - } & Record; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit: { - /** 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; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access. */ - justification?: string; - } & Record; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal: Record; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph: { - /** The immutable completion objective for every fresh Ralph round. */ - objective: string; - /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ - maxRounds?: number; - } & Record; - /** Read a UTF-8 text file and return line-numbered content. */ - read: { - /** 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; - } & Record; - /** 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: { - /** The exact skill name from the available skills list. */ - name: string; - } & Record; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Optional short reason, recorded in the log and forwarded to the task. */ - reason?: string; - } & Record; - /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list: Record; - /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ - wait?: boolean; - /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ - timeout_ms?: number; - } & Record; - /** 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). */ - todo_write: { - /** The COMPLETE task list, replacing any previous list. */ - todos: ({ - /** What the task is — a short imperative line. */ - content: string; - /** pending (not started) | in_progress (now) | completed (done). */ - status: "pending" | "in_progress" | "completed"; - })[]; - } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal: { - /** Exact id returned by get_goal. */ - goal_id: string; - /** Exact positive revision returned by get_goal. */ - revision: number; - /** edit | pause | resume | complete | blocked */ - action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ - objective?: string; - /** Replacement cap; valid only with action edit. */ - max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ - blocked_reason?: string; - } & Record; - /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */ - workflow: { - /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ - script: string; - /** The workflow identity block (plain JSON — never code). */ - meta: { - /** Short kebab-case workflow name. */ - name: string; - /** One-line description of what the workflow does. */ - description: string; - /** Optional guidance on when this workflow applies. */ - whenToUse?: string; - /** Optional phase declarations matched by phase() calls. */ - phases?: ({ - /** The phase title phase() calls match by exact string. */ - title: string; - /** Optional one-line description of the phase. */ - detail?: string; - /** Optional provider override this phase is expected to use. */ - provider?: string; - /** Optional model override this phase is expected to use. */ - model?: string; - } & Record)[]; - } & Record; - /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - } & Record; - /** Create or fully replace a UTF-8 text file. */ - write: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access. */ - justification?: string; - } & Record; -} - -interface ToolOutputMap { - bash: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - exitCode: number | null; - signal: string | null; - timedOut: boolean; - aborted: boolean; - timeoutMs: number; - stdout: { - text: string; - truncated: boolean; - spillPath?: string; - }; - stderr: { - text: string; - truncated: boolean; - spillPath?: string; - }; - sandbox?: { - mode: string; - denied: boolean; - enforcement?: string; - runnerFailed?: boolean; - }; - }; - create_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - edit: { - path: string; - before: string; - after: string; - }; - get_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - ralph: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - read: { - path: string; - offset: number; - lines: { - number: number; - text: string; - }[]; - totalLines: number; - }; - skill: { - name: string; - provider: string; - resourceBase?: { - kind: "directory"; - path: string; - } | { - kind: "url"; - url: string; - } | { - kind: "opaque"; - description: string; - }; - content: string; - }; - subagent: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - subagent_fork: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - task_kill: { - outcome: "cancellation-requested" | "already-finished"; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - task_list: ({ - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - })[]; - task_output: { - text: string; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - todo_write: { - todos: ({ - content: string; - status: "pending" | "in_progress" | "completed"; - })[]; - counts: { - pending: number; - inProgress: number; - completed: number; - }; - }; - update_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - workflow: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - write: { - path: string; - operation: "create" | "update"; - before: string | null; - after: string; - }; -} - -type ToolName = keyof ToolOutputMap - -declare class ToolCallError extends Error { - readonly name: "ToolCallError"; - readonly toolName: ToolName; -} - -declare const tools: { - [K in ToolName]: (args: ToolArgsMap[K]) => Promise; -} -``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md deleted file mode 100644 index 0cee2a6517..0000000000 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ /dev/null @@ -1,401 +0,0 @@ -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}}. - -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. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -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. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. - -## Writing code for run_code - -Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. - -The available tools: - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } - -interface ToolArgsMap { - /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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: { - /** The bash command to execute. */ - command: string; - /** 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". */ - description: string; - /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ - timeoutMs?: number; - /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ - workdir?: string; - /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). 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; - } & Record; - /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal: { - /** The concrete completion objective inferred from the direct human request. */ - objective: string; - /** Optional positive safe-integer limit on automatic continuation rounds. */ - max_goal_rounds?: number; - } & Record; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit: { - /** 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; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access. */ - justification?: string; - } & Record; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal: Record; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph: { - /** The immutable completion objective for every fresh Ralph round. */ - objective: string; - /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ - maxRounds?: number; - } & Record; - /** Read a UTF-8 text file and return line-numbered content. */ - read: { - /** 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; - } & Record; - /** 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: { - /** The exact skill name from the available skills list. */ - name: string; - } & Record; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Optional short reason, recorded in the log and forwarded to the task. */ - reason?: string; - } & Record; - /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list: Record; - /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ - wait?: boolean; - /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ - timeout_ms?: number; - } & Record; - /** 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). */ - todo_write: { - /** The COMPLETE task list, replacing any previous list. */ - todos: ({ - /** What the task is — a short imperative line. */ - content: string; - /** pending (not started) | in_progress (now) | completed (done). */ - status: "pending" | "in_progress" | "completed"; - })[]; - } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal: { - /** Exact id returned by get_goal. */ - goal_id: string; - /** Exact positive revision returned by get_goal. */ - revision: number; - /** edit | pause | resume | complete | blocked */ - action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ - objective?: string; - /** Replacement cap; valid only with action edit. */ - max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ - blocked_reason?: string; - } & Record; - /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */ - workflow: { - /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ - script: string; - /** The workflow identity block (plain JSON — never code). */ - meta: { - /** Short kebab-case workflow name. */ - name: string; - /** One-line description of what the workflow does. */ - description: string; - /** Optional guidance on when this workflow applies. */ - whenToUse?: string; - /** Optional phase declarations matched by phase() calls. */ - phases?: ({ - /** The phase title phase() calls match by exact string. */ - title: string; - /** Optional one-line description of the phase. */ - detail?: string; - /** Optional provider override this phase is expected to use. */ - provider?: string; - /** Optional model override this phase is expected to use. */ - model?: string; - } & Record)[]; - } & Record; - /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - } & Record; - /** Create or fully replace a UTF-8 text file. */ - write: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access. */ - justification?: string; - } & Record; -} - -interface ToolOutputMap { - bash: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - exitCode: number | null; - signal: string | null; - timedOut: boolean; - aborted: boolean; - timeoutMs: number; - stdout: { - text: string; - truncated: boolean; - spillPath?: string; - }; - stderr: { - text: string; - truncated: boolean; - spillPath?: string; - }; - sandbox?: { - mode: string; - denied: boolean; - enforcement?: string; - runnerFailed?: boolean; - }; - }; - create_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - edit: { - path: string; - before: string; - after: string; - }; - get_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - ralph: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - read: { - path: string; - offset: number; - lines: { - number: number; - text: string; - }[]; - totalLines: number; - }; - skill: { - name: string; - provider: string; - resourceBase?: { - kind: "directory"; - path: string; - } | { - kind: "url"; - url: string; - } | { - kind: "opaque"; - description: string; - }; - content: string; - }; - subagent: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - subagent_fork: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - task_kill: { - outcome: "cancellation-requested" | "already-finished"; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - task_list: ({ - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - })[]; - task_output: { - text: string; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - todo_write: { - todos: ({ - content: string; - status: "pending" | "in_progress" | "completed"; - })[]; - counts: { - pending: number; - inProgress: number; - completed: number; - }; - }; - update_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - workflow: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - write: { - path: string; - operation: "create" | "update"; - before: string | null; - after: string; - }; -} - -type ToolName = keyof ToolOutputMap - -declare class ToolCallError extends Error { - readonly name: "ToolCallError"; - readonly toolName: ToolName; -} - -declare const tools: { - [K in ToolName]: (args: ToolArgsMap[K]) => Promise; -} -``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json deleted file mode 100644 index a9ee29aa7a..0000000000 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "initial": [ - { - "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." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"." - } - }, - "required": [ - "code", - "description" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). 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": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "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", - "additionalProperties": false, - "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": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).", - "additionalProperties": true, - "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", - "additionalProperties": true, - "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." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "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\": [...]}).", - "additionalProperties": true - } - }, - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md deleted file mode 100644 index 17e6773a03..0000000000 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ /dev/null @@ -1,25 +0,0 @@ -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}}. 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. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -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. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). 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": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "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", - "additionalProperties": false, - "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": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).", - "additionalProperties": true, - "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", - "additionalProperties": true, - "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." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "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\": [...]}).", - "additionalProperties": true - } - }, - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). 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": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "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", - "additionalProperties": false, - "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": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).", - "additionalProperties": true, - "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", - "additionalProperties": true, - "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." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "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\": [...]}).", - "additionalProperties": true - } - }, - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index b7f09e888d..d30a2fb2a7 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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 packages/support/acp-snapshot/README.md -README.md: afbb23e2251932d41ac5d5d5b7d966f2750857fe -README.zh.md: 67ebbff395881c811819bb9e3dcfa4faa4d39914 +README.md: d0d2ae794caa35744b999ecad96d09e226ecc7ca +README.zh.md: de1e5d370e4c58cc565ec4a12fc20467a89cbed8 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index afbb23e225..d0d2ae794c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -51,11 +51,11 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. -The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). +The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 67ebbff395..de1e5d370e 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -51,11 +51,11 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 -示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 +示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 9eb74a529a..026702844e 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -8,10 +8,12 @@ * cwd and persistence roots and reads only committed fixtures. Record and * refresh stay serial while writing. * - * Exactly one scenario per header-composition class pins the full prompt and - * tool-schema sequences in dedicated sidecars. Every live header is checked - * against that pin, so session-dependent composition must declare a separate - * class instead of escaping coverage. + * Exactly one scenario per header-composition class pins the tokenized header + * sequence. Its prompt and tool-schema sequences live in independent + * sidecars, each of which may be shared with another class pin when the bytes + * are identical. Every live header is checked against the composed pin, so + * session-dependent composition must declare a separate class instead of + * escaping coverage. * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -31,10 +33,10 @@ import { scrubToolSchemas, } from './normalize.ts' -/** The readable system-prompt snapshot beside each header-pinning fixture. */ +/** The readable system-prompt snapshot beside its owning header pin. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' -/** The structured tool-schema snapshot beside each header-pinning fixture. */ +/** The structured tool-schema snapshot beside its owning header pin. */ const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json' /** The optional full Windows-native stdout transcript. */ @@ -80,10 +82,23 @@ export interface Scenario { */ overridden?: boolean /** - * Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own - * the prompt and tool schemas, while every classmate is checked for equality. + * Whether this scenario is its header class's sole tokenized request-header + * pin. Prompt and tool-schema sidecars are selected independently, while + * every classmate is checked for equality with the reconstructed header. */ pinsHeader?: boolean + /** + * Header-pinning scenario whose `system-prompt.expected.md` this pin reuses. + * Defaults to this scenario. The source must own its prompt sidecar and + * declare the same {@link expectedHeaderChanges}; meaningless off a pin. + */ + systemPromptSource?: string + /** + * Header-pinning scenario whose `tool-schemas.expected.json` this pin reuses. + * Defaults to this scenario. The source must own its schema sidecar and + * declare the same {@link expectedHeaderChanges}; meaningless off a pin. + */ + toolSchemasSource?: string /** * How many changed `request/header` snapshots this PINNING scenario's primary * fixture legitimately carries (default 0). Their full prompt text is kept in @@ -189,6 +204,71 @@ export interface SnapshotSuiteOptions { mode: 'replay' | 'record' | 'refresh' } +/** One scenario's generated claim on a shared snapshot file. */ +export interface SharedSnapshotClaim { + /** Scenario that first generated the snapshot in this suite run. */ + scenario: string + /** Complete generated file content. */ + content: string +} + +/** One committed snapshot file and its complete content. */ +export interface NamedSnapshotContent { + /** Diagnostic path of the committed file. */ + path: string + /** Complete committed file content. */ + content: string +} + +/** + * Record one scenario's generated content for a shared snapshot source. + * A later claimant must generate identical bytes; otherwise record/refresh + * would make the final file depend on scenario order. + * + * @param claims Claims already made in this suite run, keyed by source path. + * @param source The shared snapshot path being claimed. + * @param scenario The scenario generating the content. + * @param content The complete content the scenario generated. + * @returns Nothing. + */ +export function claimSharedSnapshot( + claims: Map, + source: string, + scenario: string, + content: string, +): void { + const previous = claims.get(source) + if (previous !== undefined && previous.content !== content) { + throw new Error( + `acp-snapshot: shared snapshot ${source} diverged between ${previous.scenario} and ${scenario}`, + ) + } + if (previous === undefined) claims.set(source, { scenario, content }) +} + +/** + * Reject byte-identical committed snapshots stored under different paths. + * + * @param kind Human-readable snapshot kind for the diagnostic. + * @param snapshots The committed files to compare. + * @returns Nothing. + */ +export function assertUniqueSnapshotContents( + kind: string, + snapshots: readonly NamedSnapshotContent[], +): void { + const firstPathByContent = new Map() + for (const snapshot of snapshots) { + const firstPath = firstPathByContent.get(snapshot.content) + if (firstPath !== undefined) { + throw new Error( + `acp-snapshot: identical ${kind} snapshots appear in ${firstPath} and ${snapshot.path}; reuse one source`, + ) + } + firstPathByContent.set(snapshot.content, snapshot.path) + } +} + /** * Validate and order a scenario directory's session-fixture filenames. * @@ -783,8 +863,8 @@ export function stabilizeRefreshLog( * Register the suite: one test per scenario (the expected-output and log comparisons and * the header-uniformity guard) plus the fixture guard block (no orphan * scenario dirs, required files present, exactly one pin per header class, - * pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning - * fixtures fully header-scrubbed). Must + * shared sidecars unique and well-formed, every JSONL prompt-scrubbed, + * non-pinning fixtures fully header-scrubbed). Must * run at vitest collection time — it calls `describe`/`it`. Throws * immediately if any header class lacks a pinning scenario or carries two * (the uniformity guard needs exactly one comparison anchor per class). @@ -801,6 +881,19 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { /** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */ const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default' + const scenariosByName = new Map() + for (const scenario of scenarios) { + if (scenariosByName.has(scenario.name)) { + throw new Error(`acp-snapshot: duplicate scenario name "${scenario.name}"`) + } + scenariosByName.set(scenario.name, scenario) + for (const field of ['systemPromptSource', 'toolSchemasSource'] as const) { + if (scenario[field] !== undefined && scenario.pinsHeader !== true) { + throw new Error(`acp-snapshot: ${scenario.name}.${field} is only valid on a header-pinning scenario`) + } + } + } + /** Each header class's single pinning scenario. Guarded here (and by meta-tests) so a pin cannot silently vanish or split. */ const pinningByClass = new Map() for (const scenario of scenarios) { @@ -816,6 +909,43 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } + const sourceFor = ( + pinningScenario: Scenario, + field: 'systemPromptSource' | 'toolSchemasSource', + label: string, + ): Scenario => { + const sourceName = pinningScenario[field] ?? pinningScenario.name + const source = scenariosByName.get(sourceName) + if (source === undefined) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names unknown ${label} source "${sourceName}"`) + } + if (source.pinsHeader !== true) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names non-pinning ${label} source "${sourceName}"`) + } + if (source[field] !== undefined && source[field] !== source.name) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names ${label} source "${sourceName}", which does not own its sidecar`) + } + const expectedChanges = pinningScenario.expectedHeaderChanges ?? 0 + const sourceChanges = source.expectedHeaderChanges ?? 0 + if (sourceChanges !== expectedChanges) { + throw new Error( + `acp-snapshot: ${pinningScenario.name} and ${sourceName} declare different header-change counts for shared ${label}`, + ) + } + return source + } + + const promptSourceByClass = new Map() + const schemaSourceByClass = new Map() + for (const [cls, pinningScenario] of pinningByClass) { + promptSourceByClass.set(cls, sourceFor(pinningScenario, 'systemPromptSource', 'system-prompt snapshot')) + schemaSourceByClass.set(cls, sourceFor(pinningScenario, 'toolSchemasSource', 'tool-schema snapshot')) + } + const promptOwners = new Set([...promptSourceByClass.values()].map(source => source.name)) + const schemaOwners = new Set([...schemaSourceByClass.values()].map(source => source.name)) + const promptClaims = new Map() + const schemaClaims = new Map() + scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones @@ -914,17 +1044,26 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0) - const snapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)) - await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot) + const promptSnapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)) + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario + const promptPath = join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT) + claimSharedSnapshot(promptClaims, promptPath, scenario.name, promptSnapshot) + await writeFile(promptPath, promptSnapshot) const schemaSets = normalizedToolSchemas(primary.content, ctx) expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0) expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`) .toBe(prompts.length) - await writeFile(join(dir, TOOL_SCHEMAS_SNAPSHOT), formatToolSchemasSnapshot( + const toolSchemasSnapshot = formatToolSchemasSnapshot( schemaSets[0] as unknown[], schemaSets.slice(1), - )) + ) + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario + const schemaPath = join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT) + claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot) + await writeFile(schemaPath, toolSchemasSnapshot) } } @@ -953,17 +1092,27 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // tokenized JSONL plus readable prompt and structured schema sidecars. /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */ const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? pinningScenario + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? pinningScenario const pinningDir = join(snapshotsDir, pinningScenario.name) const pinnedFixture = await readFile(join(pinningDir, 'session.jsonl'), 'utf8') const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture)) - const promptSnapshot = await readFile(join(pinningDir, SYSTEM_PROMPT_SNAPSHOT), 'utf8') + const promptSnapshot = await readFile( + join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), + 'utf8', + ) const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot) expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`) .toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0)) - const toolSchemasSnapshot = await readFile(join(pinningDir, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemasSnapshot = await readFile( + join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), + 'utf8', + ) const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) const pinnedSchemaSets = [toolSchemas.initial, ...toolSchemas.changes] - expect(pinnedSchemaSets.length, `the pinning fixture (${pinningScenario.name}) has an unexpected tool-schema count`) + expect(pinnedSchemaSets.length, `the schema source (${schemaSource.name}) has an unexpected tool-schema count`) .toBe(pinned.length) const pinnedHeaders = pinned.map((header, index) => restorePinnedToolSchemas( header, @@ -987,7 +1136,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) .toEqual(expected) if (expectedChanges === 0) { - expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) + expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(initialPromptSnapshot) } } @@ -995,12 +1144,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(formatSystemPromptSnapshot( prompts[0] as string, prompts.slice(1), - ), `session ${log.id}: changed system prompts diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) + ), `session ${log.id}: changed system prompts diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(promptSnapshot) expect(formatToolSchemasSnapshot( schemaSets[0] as unknown[], schemaSets.slice(1), - ), `session ${log.id}: changed tool schemas diverged from ${pinningScenario.name}/${TOOL_SCHEMAS_SNAPSHOT}`) + ), `session ${log.id}: changed tool schemas diverged from ${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT}`) .toEqual(toolSchemasSnapshot) } } @@ -1021,7 +1170,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { it('every registered scenario has its required fixture files', async () => { // Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars. - for (const { name, overridden, pinsHeader, pinsNativeWindowsStdout } of scenarios) { + for (const { name, overridden, pinsNativeWindowsStdout } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true) @@ -1032,17 +1181,17 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``) .toBe(overridden === true) - expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``) - .toBe(pinsHeader === true) - expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``) - .toBe(pinsHeader === true) + expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match snapshot-source ownership`) + .toBe(promptOwners.has(name)) + expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match snapshot-source ownership`) + .toBe(schemaOwners.has(name)) await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) it('exactly one scenario pins the request-header content of each header class', () => { - // Zero pins would drop a class's prompt/schema surface from the suite entirely; two would - // split it. + // Zero pins would drop a class's structural header surface from the suite entirely; two + // would split it. const pins = new Map() for (const scenario of scenarios.filter(s => s.pinsHeader === true)) { const cls = classOf(scenario) @@ -1055,33 +1204,56 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } }) - it('every pinning fixture carries one tokenized header sequence and two sidecars', async () => { + it('every pinning fixture composes one tokenized header sequence with its referenced sidecars', async () => { // Assert the committed pin directly because a class containing only its // pinning scenario has no non-pinning live run to catch undeclared changes. for (const scenario of pinningByClass.values()) { + /* v8 ignore next -- registration guarantees every pin has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario + /* v8 ignore next -- registration guarantees every pin has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8') const headers = normalizedHeaders(fixture, fixtureContext(fixture)) - const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8') + const promptSnapshot = await readFile( + join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), + 'utf8', + ) expect(headers.length, `${scenario.name}: unexpected request/header count`) .toBe(1 + (scenario.expectedHeaderChanges ?? 0)) - const toolSchemasSnapshot = await readFile(join(snapshotsDir, scenario.name, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemasSnapshot = await readFile( + join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), + 'utf8', + ) const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) const schemaSets = [toolSchemas.initial, ...toolSchemas.changes] - expect(schemaSets.length, `${scenario.name}: tool-schema sequence must match the header sequence`) + expect(schemaSets.length, `${schemaSource.name}: tool-schema sequence must match ${scenario.name}'s header sequence`) .toBe(headers.length) for (const [index, header] of headers.entries()) { expect(() => restorePinnedToolSchemas(header, schemaSets[index] as unknown[]), `${scenario.name}: tools must use the sidecar token`) .not.toThrow() } - expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0) - expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true) - expect(toolSchemasSnapshot, `${scenario.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`) + expect(promptSnapshot.length, `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0) + expect(promptSnapshot.endsWith('\n'), `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true) + expect(toolSchemasSnapshot, `${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`) .toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.changes)) expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`) .toBe(scenario.expectedHeaderChanges ?? 0) } }) + it('stores each distinct prompt and tool-schema snapshot once', async () => { + const prompts = await Promise.all([...promptOwners].map(async (owner): Promise => ({ + path: `${owner}/${SYSTEM_PROMPT_SNAPSHOT}`, + content: await readFile(join(snapshotsDir, owner, SYSTEM_PROMPT_SNAPSHOT), 'utf8'), + }))) + const schemas = await Promise.all([...schemaOwners].map(async (owner): Promise => ({ + path: `${owner}/${TOOL_SCHEMAS_SNAPSHOT}`, + content: await readFile(join(snapshotsDir, owner, TOOL_SCHEMAS_SNAPSHOT), 'utf8'), + }))) + assertUniqueSnapshotContents('system-prompt', prompts) + assertUniqueSnapshotContents('tool-schema', schemas) + }) + it('every committed JSONL has valid tool results and canonical header storage', async () => { // Prompts and schemas always leave JSONL. Header pins retain prefixes; // every other fixture tokenizes those too. Fixed-point checks make both diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json new file mode 100644 index 0000000000..4de8f25b7e --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json @@ -0,0 +1,12 @@ +{ + "prompt": "respond", + "logs": [{ + "file": "b/main/session.jsonl", + "lines": [ + { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, + { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } }, + { "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } } + ] + }] +} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json new file mode 100644 index 0000000000..b9e2d9bbc5 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json @@ -0,0 +1 @@ +{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "pin" }] } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl new file mode 100644 index 0000000000..8a9407cbc0 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl @@ -0,0 +1,4 @@ +{"type":"session","id":"13131313-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/shared-pin-cwd","delegationDepth":0} +{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":1,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"turn/start","seq":2,"time":7,"data":{"turn":1}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl new file mode 100644 index 0000000000..f173b45b77 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index a54da894a9..d68b1335d1 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { + assertUniqueSnapshotContents, + claimSharedSnapshot, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, @@ -18,6 +20,7 @@ import { scenarioSkipped, sessionFixtureNames, restorePinnedToolSchemas, + type SharedSnapshotClaim, stabilizeRefreshLog, stdoutExpectedVariants, unknownToolCallIds, @@ -57,6 +60,16 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, + { + name: 'shared-pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + expectedHeaderChanges: 1, + headerClass: 'shared', + systemPromptSource: 'pin-turn', + toolSchemasSource: 'pin-turn', + }, { name: 'plain-turn', hasModelTurn: true, @@ -211,6 +224,158 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }).toThrow(/header class "default" pinned by both first-pin and second-pin/) }) + + it('throws when scenario names are duplicated', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { name: 'duplicate', hasModelTurn: true, recorded: true, pinsHeader: true }, + { name: 'duplicate', hasModelTurn: true, recorded: true }, + ], + mode: 'replay', + }) + }).toThrow(/duplicate scenario name "duplicate"/) + }) + + it.each(['systemPromptSource', 'toolSchemasSource'] as const)( + 'rejects %s away from a header pin', + (field) => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [{ + name: 'plain', + hasModelTurn: true, + recorded: true, + [field]: 'owner', + }], + mode: 'replay', + }) + }).toThrow(new RegExp(`plain\\.${field} is only valid on a header-pinning scenario`)) + }, + ) + + it('rejects an unknown or non-pinning sidecar source', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [{ + name: 'pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + systemPromptSource: 'missing', + }], + mode: 'replay', + }) + }).toThrow(/pin names unknown system-prompt snapshot source "missing"/) + + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { + name: 'pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + toolSchemasSource: 'plain', + }, + { name: 'plain', hasModelTurn: true, recorded: true }, + ], + mode: 'replay', + }) + }).toThrow(/pin names non-pinning tool-schema snapshot source "plain"/) + }) + + it('rejects a sidecar source that redirects the same artifact', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { name: 'owner', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'redirect', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'redirect', + systemPromptSource: 'owner', + }, + { + name: 'consumer', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'consumer', + systemPromptSource: 'redirect', + }, + ], + mode: 'replay', + }) + }).toThrow(/consumer names system-prompt snapshot source "redirect", which does not own its sidecar/) + }) + + it('rejects shared sidecars with different header-change counts', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { + name: 'owner', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + expectedHeaderChanges: 1, + }, + { + name: 'consumer', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'consumer', + toolSchemasSource: 'owner', + }, + ], + mode: 'replay', + }) + }).toThrow(/consumer and owner declare different header-change counts for shared tool-schema snapshot/) + }) +}) + +describe('shared snapshot content', () => { + it('accepts identical claims and rejects order-dependent shared output', () => { + const claims = new Map() + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'first', 'prompt\n') + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'second', 'prompt\n') + expect(claims.get('shared/system-prompt.expected.md')).toEqual({ + scenario: 'first', + content: 'prompt\n', + }) + expect(() => { + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'third', 'different\n') + }).toThrow(/diverged between first and third/) + }) + + it('rejects identical committed content under different paths', () => { + assertUniqueSnapshotContents('prompt', [ + { path: 'one/system-prompt.expected.md', content: 'one\n' }, + { path: 'two/system-prompt.expected.md', content: 'two\n' }, + ]) + expect(() => { + assertUniqueSnapshotContents('prompt', [ + { path: 'one/system-prompt.expected.md', content: 'same\n' }, + { path: 'two/system-prompt.expected.md', content: 'same\n' }, + ]) + }).toThrow(/identical prompt snapshots appear in one\/system-prompt\.expected\.md and two\/system-prompt\.expected\.md/) + }) }) describe('sessionFixtureNames', () => { From cfceb8452b4f988d6501b945b419ab65f11ab7b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:31:17 +0800 Subject: [PATCH 23/61] subagent: seed inherited policy events at creation The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch. Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults. Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly. This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 51 +- .../persistence.i18n.yaml | 4 +- docs/core-data-structures/persistence.md | 22 +- docs/core-data-structures/persistence.zh.md | 22 +- docs/persistence-catalog.md | 59 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../parent-override/child.expected.jsonl | 49 +- .../tests/subagent-inheritance.snapshot.ts | 29 +- packages/acp/acp/tests/approval.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/agent/src/index.ts | 24 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 +- packages/core/session/README.zh.md | 6 +- packages/core/session/src/index.ts | 10 - packages/core/session/src/types.ts | 22 +- packages/core/session/tests/session.spec.ts | 4 - packages/core/tools/tests/tools.spec.ts | 2 +- packages/pty/pty-local/src/index.ts | 8 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 7 +- packages/sandbox/sandbox-policy/README.zh.md | 7 +- packages/sandbox/sandbox-policy/src/index.ts | 30 +- .../sandbox-policy/src/session-mode.ts | 84 +-- .../sandbox-policy/tests/policy.spec.ts | 99 +--- .../README.i18n.yaml | 6 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 10 - .../session-persistence-sqlite/src/index.ts | 10 +- .../session-persistence-sqlite/src/schema.ts | 12 +- .../tests/sqlite.spec.ts | 4 +- .../session-persistence/src/coordinator.ts | 28 - .../session-persistence/tests/contract.ts | 46 -- .../tests/coordinator-contract.ts | 69 --- .../session-query-sqlite/src/index.ts | 64 +-- .../session-query-sqlite/src/schema.ts | 6 +- .../session-query-sqlite/tests/sqlite.spec.ts | 34 -- .../session-query/src/sources.ts | 2 - .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 - .../subagent/subagent-inprocess/README.zh.md | 2 - .../subagent/subagent-inprocess/src/index.ts | 33 +- .../tests/inheritance.spec.ts | 517 ++++-------------- packages/ui/permission/README.i18n.yaml | 4 +- packages/ui/permission/README.md | 2 +- packages/ui/permission/README.zh.md | 4 +- packages/ui/permission/src/index.ts | 38 +- .../ui/permission/tests/permission.spec.ts | 57 +- packages/ui/user-approval/README.i18n.yaml | 6 +- packages/ui/user-approval/README.md | 4 +- packages/ui/user-approval/README.zh.md | 4 +- packages/ui/user-approval/src/index.ts | 105 ++-- .../ui/user-approval/tests/approval.spec.ts | 134 +---- 55 files changed, 415 insertions(+), 1371 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cbfe7539b8..f145546f2a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1971,7 +1971,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:236`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:202`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 626bc5a610..0bf299fd25 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:222`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -244,19 +244,16 @@ Approval service that applies session policy before answerers and logs every ask async request(req: ApprovalRequest): Promise /** - * {@link approvalOverrideOf} surfaced on the service, for consumers that - * reach the seam through `ctx.get('approval')` (the subagent driver's - * delegation capture) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the configured default. + * Read the session override without applying the configured default. + * @param session - session whose log supplies the override. + * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined ``` Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:251`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -817,19 +814,13 @@ Owns the deployment's permission presets and their write path. Requires a confin ```ts cordis-catalog /** - * Resolve the preset matching the effective knob values — the same - * override chains execution reads (own post-seed switches, else the - * inherited header baseline, else the composition defaults), so a - * delegated child's inherited knobs derive its real preset. A - * still-matching last selection wins shared-bundle ties, scoped like the - * knob chains: a delegation child (header baselines present) ignores - * seed-carried selections as stale parent history, while a generic fork - * child keeps them alongside its seed-carried knobs; otherwise the first - * table match wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param session - the session whose preset to derive. + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. * @returns the effective preset name, or `custom` when nothing matches. */ -current(session: Session): string +current(events: readonly SessionEvent[]): string /** * Resolve a preset's knob bundle. @@ -857,7 +848,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) @@ -1000,23 +991,19 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo ```ts cordis-catalog /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's override chain ({@link overrideOf}: own - * post-seed switches, else the inherited header baseline), which outranks - * the deployment default. A session cwd is its workspace-write boundary; - * the configured root is the fallback for agentless calls and sessions - * without a cwd. + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy /** - * {@link sandboxOverrideOf} surfaced on the service, for consumers that - * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's - * delegation capture, pty-local) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the deployment default. + * Read the session override without applying the deployment default. + * @param session - session whose log supplies the override. + * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined ``` @@ -1432,7 +1419,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:702`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 0227432913..ea83035f92 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 docs/core-data-structures/persistence.md -persistence.md: 6238182a9d570852bc1ba6e89de90427aa625b99 -persistence.zh.md: 414454dd19131a07876620e9c1e3232e155e9ccf +persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de +persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 6238182a9d..5a660e17d6 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -72,30 +72,12 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number - /** - * The sandbox-mode override inherited from the delegating parent at - * creation (the delegation-inheritance baseline). A neutral string here: - * the policy owner (`dsh-sandbox-policy`) validates it against its closed - * vocabulary on every read, this being a durable boundary. Absent for - * top-level sessions and for children of unswitched parents, which keep - * following the LIVE deployment default. Header-carried (the - * `delegationDepth` precedent) so the baseline is durable from the creation - * moment — no first-turn event survives every crash window, because an - * idle injection can persist a complete turn before any prompt turn opens. - */ - readonly sandboxMode?: string - /** - * The approval-policy override inherited from the delegating parent at - * creation. Same contract as {@link SessionHeader.sandboxMode}; validated - * by `dsh-user-approval` on read. - */ - readonly approvalPolicy?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, the inherited `sandboxMode`/`approvalPolicy` delegation baselines, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -116,8 +98,6 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 414454dd19..b5477cfc82 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -72,30 +72,12 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number - /** - * The sandbox-mode override inherited from the delegating parent at - * creation (the delegation-inheritance baseline). A neutral string here: - * the policy owner (`dsh-sandbox-policy`) validates it against its closed - * vocabulary on every read, this being a durable boundary. Absent for - * top-level sessions and for children of unswitched parents, which keep - * following the LIVE deployment default. Header-carried (the - * `delegationDepth` precedent) so the baseline is durable from the creation - * moment — no first-turn event survives every crash window, because an - * idle injection can persist a complete turn before any prompt turn opens. - */ - readonly sandboxMode?: string - /** - * The approval-policy override inherited from the delegating parent at - * creation. Same contract as {@link SessionHeader.sandboxMode}; validated - * by `dsh-user-approval` on read. - */ - readonly approvalPolicy?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`、继承的 `sandboxMode`/`approvalPolicy` 委派基线,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -116,8 +98,6 @@ interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 84f4f28acc..2b8d936a9c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) ## Events @@ -129,16 +129,19 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The last such OWN - * (post-seed) event is the session's override - * ({@link approvalOverrideOf}); who asked for it is derivable from - * position (an own event after the log's last own `request/header` was a - * runtime switch by the user). + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}). + * `source: 'delegation'` marks an override seeded into a child; an absent + * source is a runtime switch. */ -'approval/policy': { policy: ApprovalPolicy } +'approval/policy': { + policy: ApprovalPolicy + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` -Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -151,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) ### `command/*` @@ -372,7 +375,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -382,16 +385,18 @@ Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/ /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The last such OWN (post-seed) event is - * the session's override ({@link sandboxOverrideOf}); who asked for it is - * derivable from position (an event after the log's last - * `request/header*` was a runtime switch by the user; see the tool - * layer's narrator). + * never in the model transcript. The LAST such event is the session's + * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * an override seeded into a child; an absent source is a runtime switch. */ -'sandbox/mode': { mode: SandboxMode } +'sandbox/mode': { + mode: SandboxMode + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` -Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:39`](../packages/sandbox/sandbox-policy/src/session-mode.ts) +Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/sandbox/sandbox-policy/src/session-mode.ts) ### `session/*` @@ -429,7 +434,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `step/*` @@ -440,7 +445,7 @@ Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -449,7 +454,7 @@ Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) ### `todo/*` @@ -462,7 +467,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) ### `tool/*` @@ -479,7 +484,7 @@ Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -552,7 +557,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) ### `turn/*` @@ -570,7 +575,7 @@ Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -583,7 +588,7 @@ Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) ### `user/*` @@ -601,4 +606,4 @@ Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 8aa8882c11..1d41f4aa7e 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n 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];\n 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': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n 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];\n 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': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index eefcf2abb1..59a93af0f6 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -1,24 +1,25 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1,"sandboxMode":"read-only"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1} +{"type":"sandbox/mode","seq":0,"time":0,"data":{"mode":"read-only","source":"delegation"}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[2],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index 25c8ba5974..02c96cac15 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -1,15 +1,6 @@ /** - * Keyless assembled-app snapshot for parent-only policy inheritance: the - * deployment default stays WIDE (workspace-write on the shared policy home) - * while the seeded parent session carries a session-scoped `sandbox/mode: - * read-only` override; the Loader-booted headless app resumes it, the parent - * delegates through the real subagent tool, and the child's real `write` - * hits the real `dsh-fs-sandbox` fence. Only the delegation-inheritance - * capture can confine the child here — remove it and the child inherits - * nothing, writes `inherited.txt` successfully under the deployment default, - * and every assertion below fails. This is the assembled-app red/green - * anchor the ACP scenario cannot express (the automation protocol has no - * session-scoped switch). + * Assembled-app regression: a parent-only read-only override is seeded into + * its child log and confines a real write under a wider deployment default. */ import { readFile, readdir, writeFile } from 'node:fs/promises' @@ -35,11 +26,7 @@ const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Delegate the write probe to a subagent.' -/** - * Seed the parent: a completed turn whose ONLY policy fact is a session-scoped - * `sandbox/mode: read-only` switch — the deployment default stays wider, so - * the child's confinement below can come from inheritance alone. - */ +/** Seed a completed parent turn with the only read-only fact in the app. */ async function seedReadOnlyParent(root: string, cwd: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -101,8 +88,14 @@ describe('parent-only override inheritance snapshot', () => { const child = logs.find(content => typeof headerOf(content).parentSession === 'string') if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') - // The inherited baseline is the child's durable header record. - expect(headerOf(child).sandboxMode).toBe('read-only') + const childRecords = child.trimEnd().split('\n').map( + line => JSON.parse(line) as Record, + ) + expect(childRecords[1]).toMatchObject({ + type: 'sandbox/mode', + seq: 0, + data: { mode: 'read-only', source: 'delegation' }, + }) const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd } const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index bacb7b4842..01bcd83249 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -61,7 +61,7 @@ describe('ACP machine permission policy', () => { harness = await makeBridgeHarness() const request = await ownedRequest() const foreign = { - session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], header: { version: 0, id: request.agent.session.id, createdAt: 0 }, append: () => ({}) }, + session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 372bdb9023..1648bdb5c6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,7 +152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - jsDoc: '/**\n * {@link approvalOverrideOf} surfaced on the service, for consumers that\n * reach the seam through `ctx.get(\'approval\')` (the subagent driver\'s\n * delegation capture) rather than a value import.\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the configured default.\n */', + jsDoc: '/**\n * Read the session override without applying the configured default.\n * @param session - session whose log supplies the override.\n * @returns the last logged policy, or `undefined` without one.\n */', }, ], }, @@ -411,8 +411,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Owns the deployment\'s permission presets and their write path.', methods: [ { - signature: 'current(session: Session): string', - jsDoc: '/**\n * Resolve the preset matching the effective knob values — the same\n * override chains execution reads (own post-seed switches, else the\n * inherited header baseline, else the composition defaults), so a\n * delegated child\'s inherited knobs derive its real preset. A\n * still-matching last selection wins shared-bundle ties, scoped like the\n * knob chains: a delegation child (header baselines present) ignores\n * seed-carried selections as stale parent history, while a generic fork\n * child keeps them alongside its seed-carried knobs; otherwise the first\n * table match wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param session - the session whose preset to derive.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + signature: 'current(events: readonly SessionEvent[]): string', + jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', }, { signature: 'resolve(name: string): PresetSpec', @@ -500,11 +500,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy', - jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s override chain ({@link overrideOf}: own\n * post-seed switches, else the inherited header baseline), which outranks\n * the deployment default. A session cwd is its workspace-write boundary;\n * the configured root is the fallback for agentless calls and sessions\n * without a cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', + jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */', }, { signature: 'overrideOf(session: Session): SandboxMode | undefined', - jsDoc: '/**\n * {@link sandboxOverrideOf} surfaced on the service, for consumers that\n * reach policy through `ctx.get(\'sandboxPolicy\')` (the subagent driver\'s\n * delegation capture, pty-local) rather than a value import.\n * @param session - the session whose override chain to resolve.\n * @returns the effective override, or `undefined` for a session following\n * the deployment default.\n */', + jsDoc: '/**\n * Read the session override without applying the deployment default.\n * @param session - session whose log supplies the override.\n * @returns the last logged mode, or `undefined` without one.\n */', }, ], }, @@ -1593,7 +1593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateGoalRequest', @@ -1601,7 +1601,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, { name: 'DiffCallView', @@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n readonly sandboxMode?: string;\n readonly approvalPolicy?: string;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 8b37515d02..49bd2c7ce1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -47,9 +47,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, the `delegationDepth` - * recursion budget, and the inherited `sandboxMode`/`approvalPolicy` - * delegation baselines. Mirrors the corresponding fields of + * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` + * recursion budget. Mirrors the + * `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, @@ -61,20 +61,14 @@ export interface CreateAgentOptions { readonly parentSession?: SessionId readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } /** - * Seed events to reconstruct the child session's log from (the fork lineage - * primitive). When present, the factory creates the session with this event - * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the - * in-process FORK subagent backend to seed a child with a balanced - * completed-turn prefix of the parent's log. The prefix MUST be contiguous - * from seq 0, carry only lossless-JSON data, and be balanced (no open - * turn/step, no dangling tool-call), or the session constructor (and the - * dev-mode invariants replay) reject it. The factory passes the raw seed to - * the session's durable validator/snapshot boundary. Absent for a fresh - * (spawn) child. + * Initial session events. A fork starts with a balanced completed-turn + * prefix of the parent's log; creation-time log facts may follow that + * prefix. The complete seed must be contiguous from seq 0, carry only + * lossless-JSON data, and contain no open turn/step or dangling tool call. + * The factory passes it to the session's durable validator/snapshot + * boundary before publication. */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index d8fa783d44..c9bf568a38 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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 packages/core/session/README.md -README.md: 5168a08d293b71a60f23ed65c434ad6d33a7cdfe -README.zh.md: 236ee2a66efc5742765c062c20a4eadde34dffdb +README.md: 40516d12180de9c30efd40fdffa873da20ddacb3 +README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 5168a08d29..40516d1218 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. @@ -43,7 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -86,7 +86,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 236ee2a66e..43842643a3 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,7 +12,7 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 @@ -43,7 +43,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -86,7 +86,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 544e678c68..e42414503b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -143,14 +143,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } - // Neutral strings only: the owning policy packages validate the values - // against their closed vocabularies on read (durable boundary). - if (record.sandboxMode !== undefined && typeof record.sandboxMode !== 'string') { - throw new Error('session header sandboxMode must be a string') - } - if (record.approvalPolicy !== undefined && typeof record.approvalPolicy !== 'string') { - throw new Error('session header approvalPolicy must be a string') - } return deepFreeze(record as unknown as SessionHeader) } @@ -776,8 +768,6 @@ export class SessionStore extends Service { ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, - ...meta?.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, - ...meta?.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, } return new Session(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 448961da1d..f5027b2d84 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -64,24 +64,6 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number - /** - * The sandbox-mode override inherited from the delegating parent at - * creation (the delegation-inheritance baseline). A neutral string here: - * the policy owner (`dsh-sandbox-policy`) validates it against its closed - * vocabulary on every read, this being a durable boundary. Absent for - * top-level sessions and for children of unswitched parents, which keep - * following the LIVE deployment default. Header-carried (the - * `delegationDepth` precedent) so the baseline is durable from the creation - * moment — no first-turn event survives every crash window, because an - * idle injection can persist a complete turn before any prompt turn opens. - */ - readonly sandboxMode?: string - /** - * The approval-policy override inherited from the delegating parent at - * creation. Same contract as {@link SessionHeader.sandboxMode}; validated - * by `dsh-user-approval` on read. - */ - readonly approvalPolicy?: string } /** @@ -90,7 +72,7 @@ export interface SessionHeader { * store folds into a {@link SessionHeader}. */ export interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial log events supplied at construction (replay, fork, or creation-time facts). */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit @@ -102,8 +84,6 @@ export interface CreateSessionOptions { readonly createdAt?: number readonly seedLength?: number readonly delegationDepth?: number - readonly sandboxMode?: string - readonly approvalPolicy?: string } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index cd7a5d8768..7152dd5d41 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -977,8 +977,6 @@ describe('Session', () => { { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, - { header: { ...base, sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, - { header: { ...base, approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const { header, error } of cases) { @@ -1230,8 +1228,6 @@ describe('SessionStore', () => { { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, - { meta: { sandboxMode: 1 }, error: /header sandboxMode must be a string/ }, - { meta: { approvalPolicy: 1 }, error: /header approvalPolicy must be a string/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 3a6bb13a66..4149a21273 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -725,7 +725,7 @@ describe('ToolRegistry', () => { */ function fakeAgent(): Agent { return { - session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) }, + session: { events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index c133a5bb7a..d768d6bfa8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -9,13 +9,11 @@ import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -// Type-only: the `ctx.sandboxPolicy` Context merge and the `sandbox/mode` -// SessionEventMap merge; the service itself arrives via `inject`. -import type {} from '@deepseek-ai/dsh-sandbox-policy' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' @@ -49,7 +47,7 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const currentMode = state.sandboxPolicy.overrideOf(session) ?? state.sandboxPolicy.defaultMode + const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, @@ -75,7 +73,7 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] { const argv = [config.shellPath, ...config.shellArgs] - const mode: SandboxMode = ctx.sandboxPolicy.overrideOf(spec.owner.session) ?? ctx.sandboxPolicy.defaultMode + const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv return ctx.sandbox.confine(argv, { mode: mode, diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 257d47fa87..c324986a78 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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 packages/sandbox/sandbox-policy/README.md -README.md: 3e569cebb22cbed9d25642b7208e33e97041323e -README.zh.md: 2d1c30f9d79eada9b38785113a880c4760c7d0f1 +README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd +README.zh.md: abf2d9fb8830fdcaf7f1357b393b434de4a5ad8d diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 3e569cebb2..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -15,18 +15,17 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de ## Surface -- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's override chain (`overrideOf`, below), which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. +- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. -- `effectiveSandboxMode(events)` — the pure fold of a slice of `sandbox/mode` events (the last switch wins, or `undefined`), the building block `sandboxOverrideOf` composes with the seed boundary and the header baseline. +- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. -- `ctx.sandboxPolicy.overrideOf(session)` (the pure `sandboxOverrideOf` export, also consumed by the permission presets) — the session's override chain, never the deployment default: with an inherited `sandboxMode` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read (throws on foreign values — a durable boundary); without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so seed-carried switches remain the replayed inherited truth. The in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a delegating parent's tightened mode binds its children with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. ## The per-session store -A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? override chain ?? deployment default`, where the override chain is `sandboxOverrideOf`'s fold of the session's OWN post-seed switches, else the inherited header baseline — so an override survives restart by replay, a delegation child starts under its parent's captured policy, and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. +A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. ## Model Experience diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2d1c30f9d7..abf2d9fb88 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -15,18 +15,17 @@ ## 表层 -- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话的覆盖链(见下文 `overrideOf`),后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 +- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根。 -- `effectiveSandboxMode(events)`:对一段 `sandbox/mode` 事件切片的纯折叠(最后一次切换胜出,没有则为 `undefined`),是 `sandboxOverrideOf` 与种子边界和会话头基线进行组合时所用的基础构件。 +- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 -- `ctx.sandboxPolicy.overrideOf(session)`(即纯函数导出 `sandboxOverrideOf`,也供权限 preset 消费):会话的覆盖链,绝不包含部署默认值:当存在继承的 `sandboxMode` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验(遇到词汇之外的值即抛出异常——这是一条持久边界);没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的切换仍是回放所得的继承事实。进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使发起委派的父级收紧后的模式约束其子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与核心执行封闭规则。 ## 逐会话 store -运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? override chain ?? deployment default`,其中覆盖链(override chain)是 `sandboxOverrideOf` 折叠会话自己在种子之后的切换所得,否则取继承的会话头基线——因此覆盖会通过回放跨重启保留,委派子 agent 会在其父级捕获的策略下启动,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。Workspace 标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index dd35c303af..1f5ba0bb00 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,9 +19,9 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import { sandboxOverrideOf } from './session-mode.ts' +import { effectiveSandboxMode } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, sandboxOverrideOf, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -90,36 +90,28 @@ export class SandboxPolicyService extends Service { /** * Resolve the complete policy for one capability call. An approved explicit - * mode outranks the session's override chain ({@link overrideOf}: own - * post-seed switches, else the inherited header baseline), which outranks - * the deployment default. A session cwd is its workspace-write boundary; - * the configured root is the fallback for agentless calls and sessions - * without a cwd. + * mode outranks the session's last `sandbox/mode` event, which outranks the + * deployment default. A session cwd is its workspace-write boundary; the + * configured root is the fallback for agentless calls and sessions without a + * cwd. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy { const { session } = request - // Resolve the session override FIRST even when an explicit approved mode - // outranks it: the unconditional durable-header validation must hold on - // every resolution — a one-shot grant is not a validation bypass. - const override = session === undefined ? undefined : this.overrideOf(session) return { - mode: request.mode ?? override ?? this.defaultMode, + mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), } } /** - * {@link sandboxOverrideOf} surfaced on the service, for consumers that - * reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's - * delegation capture, pty-local) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the deployment default. + * Read the session override without applying the deployment default. + * @param session - session whose log supplies the override. + * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return sandboxOverrideOf(session) + return effectiveSandboxMode(session.events) } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index d2f018818a..b4cd085859 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -1,19 +1,15 @@ /** - * Per-session sandbox-mode override: the session log as the store, layered - * over the header's delegation baseline. A runtime switch (a UI policy - * control or test scenario) is recorded as one `sandbox/mode` event on the - * session it applies to; `effective = override chain ?? the deployment - * default`, where the override chain ({@link sandboxOverrideOf}) is the fold - * of the session's OWN post-seed switches, else the inherited - * `SessionHeader.sandboxMode` baseline. An override survives restart by - * replay, a delegation child starts under its parent's captured policy, two - * sessions can never see each other's state, and there is no external config - * store. The event is log-only (the `approval/*` precedent): the model - * learns the mode from the boundary markers in the enforcing tools, never - * from the event itself. EXECUTION honors the chain through - * `ctx.sandboxPolicy.resolve()` — it stamps the mode together with the - * calling session's workspace root onto each capability call, - * weakest-precedence beneath an escalation grant. + * Per-session sandbox-mode override: the session log as the store. A runtime + * switch (a UI policy control or test scenario) is recorded as one + * `sandbox/mode` event on the session it applies to; + * `effective = fold(events) ?? the deployment default`, so an override + * survives restart by replay, two sessions can never see each other's state, + * and there is no external config store. The event is log-only (the + * `approval/*` precedent): the model learns the mode from the boundary + * markers in the enforcing tools, never from the event itself. EXECUTION + * honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode + * together with the calling session's workspace root onto each capability + * call, weakest-precedence beneath an escalation grant. * * The override is policy state shared by every enforcing family (bash and * filesystem alike), so it lives here in the policy package rather than in any @@ -30,13 +26,15 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The last such OWN (post-seed) event is - * the session's override ({@link sandboxOverrideOf}); who asked for it is - * derivable from position (an event after the log's last - * `request/header*` was a runtime switch by the user; see the tool - * layer's narrator). + * never in the model transcript. The LAST such event is the session's + * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * an override seeded into a child; an absent source is a runtime switch. */ - 'sandbox/mode': { mode: SandboxMode } + 'sandbox/mode': { + mode: SandboxMode + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' + } } } @@ -44,11 +42,10 @@ declare module '@deepseek-ai/dsh-session' { export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] /** - * The pure fold of a slice of `sandbox/mode` events: the last switch wins, - * or undefined without one. The building block {@link sandboxOverrideOf} - * composes with the seed boundary and the header baseline — consumers - * resolving a SESSION's policy go through that chain, not this raw fold. - * Resume needs no catch-up machinery because replaying the log IS the state. + * The session's sandbox-mode override: the last `sandbox/mode` event in the + * log, or undefined when the session never switched (callers apply the + * deployment 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 mode of the last switch event, or undefined without one. */ @@ -60,41 +57,6 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo return undefined } -/** - * The session's complete sandbox-mode OVERRIDE chain — the one home every - * consumer (the policy service, the permission presets) resolves through. - * With a header baseline (a delegation child), the fold covers only the - * session's OWN switches past the seed boundary — the baseline was captured - * from the parent's FULL log at delegation, so any seed-carried switch is - * already subsumed by it, stale or not. Without a baseline (a top-level - * session, or a generic `SessionStore.fork` child that captured no policy - * meta), the fold covers the whole log: seeded switches ARE the replayed - * inherited truth, and slicing them away would silently widen the child to - * the deployment default. Never the deployment default itself. The durable - * baseline is validated UNCONDITIONALLY — a corrupt or foreign header must - * fail loud on every read, not only when no own switch happens to shadow it. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the deployment default. - * @throws when the header baseline is outside the closed mode vocabulary. - */ -export function sandboxOverrideOf(session: Session): SandboxMode | undefined { - const baseline = session.header.sandboxMode - if (baseline === undefined) return effectiveSandboxMode(session.events) - if (!SANDBOX_MODES.includes(baseline as SandboxMode)) { - throw new Error(`session header sandboxMode "${baseline}" is outside the closed mode vocabulary`) - } - // A boundary past the log would make the own-switch slice empty until the - // log grows past it — a wide baseline would then shadow a REAL later - // tightening. Malformed durable metadata fails loud, never fails open. - const seedLength = session.header.seedLength ?? 0 - if (seedLength > session.events.length) { - throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) - } - const own = effectiveSandboxMode(session.events.slice(seedLength)) - return own ?? baseline as SandboxMode -} - /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index b0362b6592..63ca0cd3d5 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -63,6 +63,8 @@ describe('SandboxPolicyService', () => { mode: 'read-only', workspaceRoot: resolve('/projects/second'), }) + expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() + expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), @@ -142,100 +144,3 @@ describe('the sandbox/mode session kit', () => { expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) }) - -describe('delegation inheritance (overrideOf over the header baseline)', () => { - /** A session whose header carries the delegation-inheritance baseline. */ - function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session { - const sessionId = SessionId(id) - return new Session(sessionId, undefined, { - version: 0, - id: sessionId, - createdAt: 0, - ...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode }, - ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, - }) - } - - it('overrideOf folds the session log and never falls back to the deployment default', async () => { - const ctx = await mounted({ mode: 'workspace-write' }) - const parent = session('sess-inherit-parent') - setSandboxMode(parent, 'workspace-write') - setSandboxMode(parent, 'read-only') - - expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only') - // undefined, NOT the deployment default — a child whose header froze the - // default would stop following the LIVE default across resumes. - expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined() - }) - - it('overrideOf reads the header baseline when the log has no own switch', async () => { - const ctx = await mounted({ mode: 'workspace-write' }) - const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' }) - - expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') - // resolve() consumes the same chain, so enforcement sees the baseline. - expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') - }) - - it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { - const ctx = await mounted({ mode: 'workspace-write' }) - // The fork seed carried the parent's OLD workspace-write switch (one - // event, so seedLength 1); the delegation-time baseline is read-only. - const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 }) - setSandboxMode(child, 'workspace-write') - expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') - // A switch the child makes ITSELF (after the seed boundary) outranks it. - setSandboxMode(child, 'danger-full-access') - expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access') - }) - - it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' }) - - expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) - }) - - it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-inherit-invalid-own', { sandboxMode: 'yolo' }) - // A corrupt or foreign durable record must fail loud on EVERY read — an - // own override must not paper over the malformed header. - setSandboxMode(child, 'read-only') - - expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/) - }) - - it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { - const ctx = await mounted({ mode: 'workspace-write' }) - // The public fork path sets seedLength but captures no delegation - // baseline; the seed boundary must not discard the replayed policy state - // it exists to subsume — with nothing to subsume it, seeded switches ARE - // the child's inherited truth. - const child = inheritedSession('sess-generic-fork', { seedLength: 1 }) - setSandboxMode(child, 'read-only') - - expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only') - expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only') - }) - - it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { - const ctx = await mounted() - // A malformed durable seedLength beyond the log would make the own-switch - // slice empty until the log grows past it — a wide baseline would then - // shadow a REAL later tightening. Fail loud at the durable boundary. - const child = inheritedSession('sess-inherit-oob', { sandboxMode: 'danger-full-access', seedLength: 100 }) - setSandboxMode(child, 'read-only') - - expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/seedLength/) - }) - - it('resolve() validates the durable header even when an explicit approved mode is supplied', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-resolve-invalid', { sandboxMode: 'yolo' }) - - // The explicit one-shot grant must not become a validation bypass: the - // unconditional durable-header contract holds on EVERY resolution. - expect(() => ctx.sandboxPolicy.resolve({ session: child, mode: 'workspace-write' })).toThrow(/sandboxMode/) - }) -}) diff --git a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml index 2cf403339f..f817c87919 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session-persistence/session-persistence-jsonl/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-jsonl/README.md -README.md: 2f68b3d02fecb61042964e69edbb446219a02e25 -README.zh.md: 74a73d1c48ddc2ef0de4679489b8494b7364eee5 +# pnpm run verify-translation-pairing --write +README.md: ab6ecd28f12bd167aeac789d1565705e167d60f4 +README.zh.md: 97d387a04fa4c658217e28619410a49b7e6d4ec0 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 2f68b3d02f..ab6ecd28f1 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session-persistence/session-persistence-jsonl/README.zh.md b/packages/session-persistence/session-persistence-jsonl/README.zh.md index 74a73d1c48..97d387a04f 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.zh.md +++ b/packages/session-persistence/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 1032096943..03e8b15da0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -38,8 +38,6 @@ export interface HeaderLine { parentSession?: SessionId seedLength?: number delegationDepth: number - sandboxMode?: string - approvalPolicy?: string } /** @@ -57,8 +55,6 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, delegationDepth: header.delegationDepth ?? 0, - ...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {}, - ...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {}, } } @@ -76,8 +72,6 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, delegationDepth: line.delegationDepth, - ...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {}, - ...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {}, } } @@ -96,10 +90,6 @@ function isHeaderLine(value: unknown): value is HeaderLine { && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) - && ((value as { sandboxMode?: unknown }).sandboxMode === undefined - || typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string') - && ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined - || typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string') ) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 8d98fbdf2d..f771b9e3a7 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -301,17 +301,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, parent_session = excluded.parent_session, seed_length = excluded.seed_length, - delegation_depth = excluded.delegation_depth, - sandbox_mode = excluded.sandbox_mode, - approval_policy = excluded.approval_policy + delegation_depth = excluded.delegation_depth `).run( meta.id, meta.version, @@ -321,8 +319,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.delegationDepth ?? null, randomUUID(), - meta.sandboxMode ?? null, - meta.approvalPolicy ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index f8e3285ba2..754d9d7e63 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 11 +export const SCHEMA_VERSION = 10 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -41,10 +41,6 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null - /** The inherited sandbox-mode delegation baseline, or NULL. */ - sandbox_mode: string | null - /** The inherited approval-policy delegation baseline, or NULL. */ - approval_policy: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -128,9 +124,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, delegation_depth INTEGER, incarnation TEXT NOT NULL, - revision INTEGER NOT NULL, - sandbox_mode TEXT, - approval_policy TEXT + revision INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS events ( @@ -187,8 +181,6 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, - ...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {}, - ...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 6c4db46e9b..82f00519ba 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -171,8 +171,6 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, - sandbox_mode: null, - approval_policy: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) }) @@ -611,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(11) + expect(SCHEMA_VERSION).toBe(10) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 68c5ab744f..878dddcb42 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -118,32 +118,6 @@ async function settledErrors(promises: Iterable>): Promise { if (tracked.meta.cwd !== session.header.cwd) { throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - assertSamePolicyBaselines(id, tracked.meta, session.header) if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) } @@ -640,7 +613,6 @@ export class PersistenceCoordinator { if (meta.cwd !== session.header.cwd) { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - assertSamePolicyBaselines(session.header.id, meta, session.header) this.assertVersion(meta) assertSupportedEvents(events, session.header.id) if (!seedCoversPrefix(seed, events)) { diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 17d54f4be5..24a76b02cd 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -99,36 +99,6 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - // A delegated child header: the sandbox/approval baselines must - // survive storage verbatim — a resumed child falling back to the - // deployment default would reopen the delegation bypass. - const child: SessionHeader = { - ...meta('s-baseline', '/work'), - delegationDepth: 1, - sandboxMode: 'read-only', - approvalPolicy: 'never', - } - await persistence.create(child) - await persistence.append(child.id, oneTurnLog()) - const loaded = await persistence.load(child.id) - expect(loaded.meta).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - - // A top-level header: absent baselines stay ABSENT (not null/empty) — - // presence is the signal the policy owners branch on. - const top = meta('s-no-baseline', '/work') - await persistence.create(top) - await persistence.append(top.id, oneTurnLog()) - const reloaded = await persistence.load(top.id) - expect('sandboxMode' in reloaded.meta).toBe(false) - expect('approvalPolicy' in reloaded.meta).toBe(false) - } finally { - await dispose() - } - }) - it('rejects a fractional creation timestamp without reserving its session id', async () => { const { persistence, dispose } = await make() try { @@ -307,22 +277,6 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - // The abort observer must not swallow an ordinary success: a signal - // that stays quiet leaves the queued operation's resolution intact. - const m = meta('signal-quiet-inspect', '/work') - await persistence.create(m) - await persistence.append(m.id, oneTurnLog()) - const controller = new AbortController() - await expect(persistence.inspect(m.id, controller.signal)) - .resolves.toMatchObject({ meta: { id: m.id } }) - } finally { - await dispose() - } - }) - it('rejects pre-aborted observation reads with the exact cancellation reason', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 95399effd9..d63cb857a2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -753,75 +753,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // A stored artifact carrying a WIDE baseline. A same-id live session - // claiming a NARROW baseline must be rejected: adoption retains the - // stored header, so accepting the pair would let the session append - // under read-only now but resume under danger-full-access later. - await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' }) - await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('baseline-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, sandboxMode: 'read-only' }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('a live session with a DIFFERENT seed boundary cannot adopt a stored prefix when a baseline exists', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // Same wide baseline both sides, but the stored header says event 0 is - // seed-carried (seedLength 1) while the live header says it is the - // session's OWN (seedLength 0). overrideOf() resolves policy through - // that boundary: a read-only switch at event 0 tightens the live - // session, yet a restart resumes under the stored header and the wide - // baseline silently returns. The boundary is part of the policy - // identity whenever a baseline exists. - await ctx.sessionPersistence.create({ - ...meta('seed-boundary-conflict', WORK), - sandboxMode: 'danger-full-access', - seedLength: 1, - }) - await ctx.sessionPersistence.append(SessionId('seed-boundary-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('seed-boundary-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, sandboxMode: 'danger-full-access' }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - - it('an approval-only baseline also pins the seed boundary (the other baseline arm)', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - // The boundary guard triggers off EITHER baseline: a stored header - // with only approvalPolicy (no sandboxMode, no seedLength) must still - // reject a live twin whose boundary differs. - await ctx.sessionPersistence.create({ ...meta('approval-boundary-conflict', WORK), approvalPolicy: 'never' }) - await ctx.sessionPersistence.append(SessionId('approval-boundary-conflict'), oneTurnLog()) - const live = ctx.sessions.create(SessionId('approval-boundary-conflict'), { - seed: oneTurnLog(), - meta: { cwd: WORK, approvalPolicy: 'never', seedLength: 2 }, - }) - await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/) - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index ce89921ade..0c073ccea5 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -146,8 +146,6 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null - sandbox_mode: string | null - approval_policy: string | null } interface SearchRow extends SessionHeaderRow { @@ -526,23 +524,6 @@ export class SessionQuerySqlite extends SessionQueryService { } } - /** The shared header column bindings both session tables lead with. */ - private static _headerBindings( - header: SessionHeader, - ): [string, number, number, string | null, string | null, number | null, number | null, string | null, string | null] { - return [ - header.id, - header.version, - header.createdAt, - header.cwd ?? null, - header.parentSession ?? null, - header.seedLength ?? null, - header.delegationDepth ?? null, - header.sandboxMode ?? null, - header.approvalPolicy ?? null, - ] - } - private _replacePersistedSession( entry: ObservedSession, revision: SessionPersistenceRevision, @@ -552,9 +533,19 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(...SessionQuerySqlite._headerBindings(entry.header), revision, generation) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.header.delegationDepth ?? null, + revision, + generation, + ) const insert = db.prepare(` INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -578,9 +569,20 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(...SessionQuerySqlite._headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.header.delegationDepth ?? null, + entry.fingerprint, + persisted ? 1 : 0, + generation, + ) const insert = db.prepare(` INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -669,7 +671,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -679,7 +681,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, sandbox_mode, approval_policy, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -738,8 +740,6 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, - ps.sandbox_mode AS sandbox_mode, - ps.approval_policy AS approval_policy, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -762,8 +762,6 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, - ls.sandbox_mode AS sandbox_mode, - ls.approval_policy AS approval_policy, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -872,8 +870,6 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) - && a.sandboxMode === b.sandboxMode - && a.approvalPolicy === b.approvalPolicy } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -885,8 +881,6 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, - ...row.sandbox_mode === null ? {} : { sandboxMode: row.sandbox_mode }, - ...row.approval_policy === null ? {} : { approvalPolicy: row.approval_policy }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 1bd832aa47..47f6374ba6 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -117,8 +117,6 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, - sandbox_mode TEXT, - approval_policy TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -148,8 +146,6 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, - sandbox_mode TEXT, - approval_policy TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index bf0718f0b6..f7d2b3b352 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -225,40 +225,6 @@ describe('SQLite session search', () => { .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) - it('round-trips the inherited policy baselines through search headers', async () => { - // A delegated child's header carries the sandbox/approval baselines; the - // derived index must return them — a consumer resuming from a search hit - // would otherwise rebuild a child without its inherited confinement. - const ctx = await liveContext({ path: ':memory:' }) - const session = ctx.sessions.create(SessionId('live-baseline'), { - meta: { cwd: '/work', createdAt: 10, sandboxMode: 'read-only', approvalPolicy: 'never' }, - }) - session.append( - 'user/message', - createUserMessage({ content: [{ type: 'text', text: 'baseline needle' }], source: { kind: 'user' } }), - { surfaceOp: 'append' }, - ) - - const result = await ctx.sessionQuery.searchSessions({ query: 'needle' }) - expect(result.items[0]?.header).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - const events = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle' }) - expect(events.session).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' }) - }) - - it('rejects live/persisted sources whose policy baselines conflict', async () => { - const shared = header('baseline-conflict', 10, { sandboxMode: 'read-only' }) - TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - ctx.sessions.create(shared.id, { - seed: messageEvents('live needle'), - meta: { createdAt: 10, sandboxMode: 'danger-full-access' }, - }) - - await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) - .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) - }) - it('searches all surfaces by default and applies metadata before ranking', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const parent = SessionId('parent') diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts index 2daccb6b28..f1bb64275f 100644 --- a/packages/session-query/session-query/src/sources.ts +++ b/packages/session-query/session-query/src/sources.ts @@ -17,8 +17,6 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade || a.parentSession !== b.parentSession || a.seedLength !== b.seedLength || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) - || a.sandboxMode !== b.sandboxMode - || a.approvalPolicy !== b.approvalPolicy ) { throw new SessionQueryError( `session source headers conflict for session "${a.id}"`, diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index c9c3a25249..621b896045 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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 packages/subagent/subagent-inprocess/README.md -README.md: 95a45cd7a1f4510601f7f8d8bf396e7262f1a3cf -README.zh.md: c20f5106d0009f9c5507e830361c0fcba54d6280 +README.md: 3606799e6d16e80473006f82b834a10953270914 +README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 95a45cd7a1..3606799e6d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,8 +18,6 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. -The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). - ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index c20f5106d0..7f52d3699d 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,8 +18,6 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 -子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)` 与 `ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值;`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 - ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index c24bafa4ad..50174037f8 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -102,17 +102,28 @@ export async function startInProcessRun( subagentDepth: childDepth, } - // Policy inheritance: capture the parent's sandbox/approval OVERRIDES - // synchronously, before the first await — the delegation moment is the - // semantic snapshot point, and a parent switch racing the child's - // asynchronous creation must belong to the parent's future, not the child. - // The captured values ride the child's creation meta into its immutable - // header, so the baseline is durable from the moment the session exists — - // no first-turn event could survive every crash window (an idle injection - // can persist a complete turn before any prompt turn opens). Both services - // are consumed opportunistically — without them, delegation is policy-free. + // Capture before the first await: a later parent switch belongs to the + // parent's future. Appending after the fork prefix makes the captured + // values the child's initial overrides without another storage plane. const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) + const seed: SessionEvent[] = [...options.seed ?? []] + if (inheritedMode !== undefined) { + seed.push({ + type: 'sandbox/mode', + seq: seed.length, + time: Date.now(), + data: { mode: inheritedMode, source: 'delegation' }, + }) + } + if (inheritedPolicy !== undefined) { + seed.push({ + type: 'approval/policy', + seq: seed.length, + time: Date.now(), + data: { policy: inheritedPolicy, source: 'delegation' }, + }) + } let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { @@ -134,10 +145,8 @@ export async function startInProcessRun( // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, ...seedLength > 0 ? { seedLength } : {}, - ...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {}, - ...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {}, }, - ...options.seed !== undefined ? { seed: options.seed } : {}, + ...(options.seed !== undefined || seed.length > 0) ? { seed } : {}, agentOptions, signal: request.signal, setup, diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 47147bd3eb..07ba55ccfa 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -1,39 +1,17 @@ -/** - * Policy inheritance from parent to in-process child agents, proven against - * the REAL enforcement wall: a real loop drives a scripted mock MODEL whose - * children hit the real `dsh-fs-sandbox` fence through the real `write` tool, - * and every claim is asserted on physical facts — does the file exist on - * disk, what denial text landed in the child's tool result. Nothing here asks - * the policy service what it WOULD do; the child either writes or is denied. - * - * Red/green anchor for the delegation-bypass gap: a parent switched to - * `read-only` must not mint children that run under the (wider) deployment - * default. The captured overrides ride the child's creation-time header, so - * three review-found timing threats are pinned as distinct shapes: a parent - * switch racing the asynchronous creation, a veto-capable prompt-submit - * listener closing a promptless first turn, and an injection-persisted turn - * before any prompt turn opens (header asserted before the child runs). - */ +/** Policy inheritance through constructor-seeded child session events. */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' -import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' -import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService from '@deepseek-ai/dsh-subagent' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,130 +20,36 @@ import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]' - +const contexts: Context[] = [] let workspace: string beforeEach(async () => { - // realpath: macOS tmpdir is symlinked (/var → /private/var); resolve once so - // path assertions and the fence's canonicalization agree on one spelling. workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-'))) }) + afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() await rm(workspace, { recursive: true, force: true }) }) -async function mountInvariants(ctx: Context): Promise { - await ctx.plugin(InvariantService) - await ctx.plugin(SessionInvariant) - await ctx.plugin(AgentInvariant) - await ctx.plugin(AgentLoopInvariant) -} - -/** - * The walled composition: real loop + real sandbox-policy home + the real - * confining filesystem backend + the real `write` tool + the approval seam - * (mounted with NO answerer — the in-process child reality). The deployment - * default is deliberately WIDER (`workspace-write`) than the parent's staged - * `read-only` override, so a child that fails to inherit visibly escapes. - * - * The script array is taken by reference and filled by each test AFTER the - * parent exists, so scripted side-effect entries can close over it. - */ -async function setupWalled(script: Script) { +async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await mountInvariants(ctx) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) await ctx.plugin(ApprovalService) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }, { cwd: workspace }) + const parent = ctx.agentLoop.create( + SessionId('parent'), + { provider: 'mock', model: 'mock' }, + { cwd: workspace }, + ) return { ctx, parent } } -/** Bare composition: no sandbox, no fs, no approval — delegation must not care. */ -async function setupBare(script: Script) { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await mountInvariants(ctx) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) - ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } -} - -/** - * Register the delegation scratch tool: delegating from INSIDE an open parent - * turn is exactly the real tool-subagent shape, and it is what makes the - * "user switched while idle, model delegates in the very next turn" fork - * timing constructible (the post-seed switch lives in the still-open turn). - * `fork: true` seeds the child with the caller's completed-turn prefix, - * mirroring the fork provider's slice. `raceSwitch` flips the CALLER's mode - * synchronously after `startInProcessRun`'s synchronous prologue but before - * its creation transaction resolves — the delegation-vs-late-switch race. - */ -function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-full-access'): void { - ctx.tools.register(defineTool({ - name: 'delegate', - description: 'delegate a task to an in-process child (test scaffold)', - parameters: { fork: { type: 'boolean', description: 'seed the child with the completed-turn prefix' } }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - stopReason: { type: 'string', required: true }, - }, - }, - render: (_args, value) => [{ type: 'text', text: `child:${(value).stopReason}` }], - }, - async execute(args, exec) { - const caller = exec.agent - if (caller === undefined) throw new Error('delegate scaffold requires a calling agent') - const events = caller.session.events - const lastEnd = events.findLast(e => e.type === 'turn/end') - const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1) - const starting = startInProcessRun( - { prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal }, - args.fork === true && seed.length > 0 ? { seed } : {}, - ) - // The caller's turn is still open, so this switch is legal — and it lands - // while the child's creation transaction is pending, strictly before the - // child's first prompt-submit could ever run. - if (raceSwitch !== undefined) setSandboxMode(caller.session, raceSwitch) - const run = await starting - captured.push(run.localAgent as Agent) - const result = await run.result - await run.dispose() - return { stopReason: result.stopReason } - }, - })) -} - -/** All tool/result texts in a session log, in order. */ -function toolResultTexts(agent: Agent): string[] { - return agent.session.events - .filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') - .map(e => e.data.message.content - .flatMap(block => block.content) - .filter((block): block is Extract => block.type === 'text') - .map(block => block.text) - .join('')) -} - -/** Count the policy-override events in a session log. */ -function overrideEvents(agent: Agent): { sandbox: number; approval: number } { - const events = agent.session.events - return { - sandbox: events.filter(e => e.type === 'sandbox/mode').length, - approval: events.filter(e => e.type === 'approval/policy').length, - } -} - function spawnRequest(parent: Agent) { return { prompt: [{ type: 'text' as const, text: 'child task' }], @@ -174,320 +58,125 @@ function spawnRequest(parent: Agent) { } } -describe('sandbox-mode inheritance against the real fs fence', () => { - it('a SPAWN child of a read-only parent is denied by the real fence (no file on disk)', async () => { +function toolResultTexts(agent: Agent): string[] { + return agent.session.events + .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + .map(event => event.data.message.content + .flatMap(block => block.content) + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('')) +} + +describe('in-process policy inheritance', () => { + it('seeds parent overrides into a spawn child before its first request', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') - script.push( - // The switch is staged INSIDE a parent turn — the same turn-enclosed - // anchoring every real switch path (ACP pending switches) uses. - () => { - setSandboxMode(parent.session, 'read-only') - setApprovalPolicy(parent.session, 'never') - return textResponse('staged') - }, - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('child done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage the session policy' }], source: { kind: 'user' } })) - await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') + setApprovalPolicy(parent.session, 'never') const parentLogLength = parent.session.events.length - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - // The physical fact: the write never reached the disk. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - // The model-visible fact: the child saw the read-only denial marker. - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(result.stopReason).toBe('completed') - - // The inherited baseline is part of the child's IMMUTABLE header — - // durable from the creation moment, with no first-turn timing window - // (a crash after any persisted turn still resumes with the baseline). - expect(child.session.header.sandboxMode).toBe('read-only') - expect(child.session.header.approvalPolicy).toBe('never') - // The log stays free of stamped events: the header is the one home. - expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) - // What the enforcing families resolve for the child, end to end. - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - // Inheritance reads the parent log, never writes it. - expect(parent.session.events.length).toBe(parentLogLength) - - await run.dispose() - }) - - it('the baseline is durable BEFORE any child turn exists (the injection-turn crash window)', async () => { - // The review scenario: a SessionStart-style idle injection can persist a - // complete turn before the first prompt turn opens. The baseline must - // already be durable then — it is, because it rides the creation-time - // header, not a first-turn event. - const script: Script = [] - const { parent } = await setupWalled(script) script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, + toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() const run = await startInProcessRun(spawnRequest(parent), {}) - const child = run.localAgent as Agent - // Assert on the HEADER immediately after publication — before the child's - // first turn has run (run.result not yet awaited). An idle injection - // persisting a turn now would carry the baseline with it. - expect(child.session.header.sandboxMode).toBe('read-only') - await run.result - await run.dispose() + try { + const result = await run.result + const child = run.localAgent as Agent + + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) + expect(result.stopReason).toBe('completed') + expect(child.session.events.slice(0, 2)).toMatchObject([ + { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, + { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, + ]) + expect(child.session.firstLiveSeq).toBe(2) + expect(child.session.header.seedLength).toBeUndefined() + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + expect(ctx.approval.overrideOf(child.session)).toBe('never') + const request = child.session.events.find( + (event): event is SessionEvent<'request/header'> => event.type === 'request/header', + ) + expect(request?.data.header.system).toContain('Approval prompts are disabled') + expect(parent.session.events).toHaveLength(parentLogLength) + } finally { + await run.dispose() + } }) - it('a FORK child inherits the parent switch made AFTER the seed boundary (stale-seed timing)', async () => { + it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { const script: Script = [] - const captured: Agent[] = [] const { ctx, parent } = await setupWalled(script) - registerDelegate(ctx, captured) const blocked = join(workspace, 'fork-blocked.txt') + setSandboxMode(parent.session, 'workspace-write') + const seed = [...parent.session.events] + setSandboxMode(parent.session, 'read-only') script.push( - // Turn 1: the OLD, wider switch — this one lands in the fork seed. - () => { - setSandboxMode(parent.session, 'workspace-write') - return textResponse('turn one') - }, - // Turn 2: the user tightened to read-only, then the model delegates in - // the SAME turn — the switch is in the log but past the seed slice. - () => { - setSandboxMode(parent.session, 'read-only') - return toolCallResponse('d-fork', 'delegate', { fork: true }) - }, - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('fork child done'), - textResponse('turn two done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn one' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'turn two: delegate' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const child = captured[0] as Agent - // The seed really carried the stale workspace-write switch… - expect(child.session.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) - // …and the newest parent state still won, on disk and in resolution. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - }) - - it('inherits the mode AT delegation, not a parent switch racing child creation', async () => { - const script: Script = [] - const captured: Agent[] = [] - const { ctx, parent } = await setupWalled(script) - // The delegate scaffold flips the parent to danger-full-access AFTER - // startInProcessRun's synchronous prologue, while the child's creation - // transaction is still pending — the value at delegation is read-only. - registerDelegate(ctx, captured, 'danger-full-access') - const blocked = join(workspace, 'race-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - toolCallResponse('d-race', 'delegate', { fork: false }), - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('race child done'), - textResponse('turn two done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const child = captured[0] as Agent - // The child runs under the snapshot taken at delegation — the racing - // wider switch belongs to the parent's own future, not to the child. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - }) - - it('a GRANDCHILD inherits through the chain (child delegates again)', async () => { - const script: Script = [] - const captured: Agent[] = [] - const { ctx, parent } = await setupWalled(script) - registerDelegate(ctx, captured) - const blocked = join(workspace, 'grandchild-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - toolCallResponse('d-child', 'delegate', { fork: false }), - // Child immediately delegates the write to a grandchild. - toolCallResponse('d-grandchild', 'delegate', { fork: false }), - toolCallResponse('g-write', 'write', { file_path: blocked, content: 'escaped' }), - textResponse('grandchild done'), + toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), - textResponse('parent done'), ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'delegate twice' }], source: { kind: 'user' } })) - await parent.whenIdle() - expect(captured).toHaveLength(2) - const grandchild = captured[1] as Agent - expect(grandchild.session.header.delegationDepth).toBe(2) - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - expect(toolResultTexts(grandchild).join('\n')).toContain(READ_ONLY_DENIAL) - expect(ctx.sandboxPolicy.resolve({ session: grandchild.session }).mode).toBe('read-only') + const run = await startInProcessRun(spawnRequest(parent), { seed }) + try { + await run.result + const child = run.localAgent as Agent + + expect(child.session.header.seedLength).toBe(1) + expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { seq: 0, data: { mode: 'workspace-write' } }, + { seq: 1, data: { mode: 'read-only', source: 'delegation' } }, + ]) + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + + setSandboxMode(child.session, 'danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + } finally { + await run.dispose() + } }) -}) -describe('inheritance survives prompt vetoes', () => { - it('keeps the baseline when an earlier-registered prompt-submit listener vetoes without next()', async () => { - const script: Script = [] + it('captures policy at delegation before asynchronous child creation', async () => { + const script: Script = [textResponse('child done')] const { ctx, parent } = await setupWalled(script) - // A veto-capable listener registered BEFORE the child exists — the - // Claude/Codex UserPromptSubmit hook shape: it blocks the child's prompt - // and never delegates. Inheritance must still run for the first turn. - ctx.on('agent/prompt-submit', (agent, _message, _signal, next) => { - if (agent.session.header.parentSession !== undefined) { - return Promise.resolve({ kind: 'block' as const, reason: 'vetoed by test hook' }) - } - return next() - }) - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - // No child model entries: the blocked prompt closes a zero-step turn. - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') - const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // The veto closed the first turn promptless, but the baseline rides the - // creation-time header — no listener ordering can starve it, and a later - // resume must not fall back to the deployment default just because the - // first prompt was blocked. - expect(child.session.header.sandboxMode).toBe('read-only') - expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only') - - await run.dispose() + const starting = startInProcessRun(spawnRequest(parent), {}) + setSandboxMode(parent.session, 'danger-full-access') + const run = await starting + try { + await run.result + const child = run.localAgent as Agent + expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + } finally { + await run.dispose() + } }) -}) -describe('inheritance guards (must hold before AND after the fix)', () => { - it('a child of an unswitched parent runs under the live deployment default, with NO baseline or events', async () => { + it('does not freeze deployment defaults into an unswitched child', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') script.push( - toolCallResponse('c-write', 'write', { file_path: allowed, content: 'fine' }), + toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }), textResponse('child done'), ) const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // workspace-write (the deployment default) really allowed the write… - expect(await readFile(allowed, 'utf8')).toBe('fine') - // …and nothing froze that default into the child header or log. - expect(child.session.header.sandboxMode).toBeUndefined() - expect(child.session.header.approvalPolicy).toBeUndefined() - expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) - - await run.dispose() - }) - - it('delegation works unchanged when no sandbox/approval services are composed at all', async () => { - const script: Script = [] - const { parent } = await setupBare(script) - script.push(textResponse('bare child answer')) - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - expect(result.stopReason).toBe('completed') - expect(overrideEvents(child)).toEqual({ sandbox: 0, approval: 0 }) - - await run.dispose() - }) -}) - -describe('what a blocked child experiences', () => { - it('an inherited "never" policy is stated in the child FIRST request system prompt', async () => { - const script: Script = [] - const { parent } = await setupWalled(script) - script.push( - () => { - setApprovalPolicy(parent.session, 'never') - return textResponse('staged') - }, - textResponse('child done'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const run = await startInProcessRun(spawnRequest(parent), {}) - await run.result - const child = run.localAgent as Agent - - // Model-visible ⟺ logged: the child was TOLD up front not to request - // escalation, in the very first request it ever saw. - const header = child.session.events.find((e): e is SessionEvent<'request/header'> => e.type === 'request/header') - expect(header?.data.header.system).toContain('Approval prompts are disabled') - - await run.dispose() - }) - - it('a denied child that retries with sandbox_permissions fails closed on the REAL escalation gate', async () => { - const script: Script = [] - const { parent } = await setupWalled(script) - const blocked = join(workspace, 'escalation-blocked.txt') - script.push( - () => { - setSandboxMode(parent.session, 'read-only') - return textResponse('staged') - }, - // First attempt: denied by the fence. - toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }), - // One-shot escalation retry, exactly as the denial hint teaches — the - // approval seam is mounted but NO answerer owns an in-process child. - toolCallResponse('c-escalate', 'write', { - file_path: blocked, - content: 'escaped', - sandbox_permissions: 'workspace-write', - justification: 'the test child wants to write inside the workspace', - }), - textResponse('child gave up'), - ) - parent.followup(createUserMessage({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })) - await parent.whenIdle() - - const run = await startInProcessRun(spawnRequest(parent), {}) - const result = await run.result - const child = run.localAgent as Agent - - // Nothing ever reached the disk — not the first attempt, not the retry. - await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - const results = toolResultTexts(child) - expect(results[0]).toContain(READ_ONLY_DENIAL) - // The child's escalation resolves through the real approval waterfall to - // the distinct fail-closed reason — the honest "report upward" signal. - expect(results[1]).toContain('no approval channel is available') - expect(result.stopReason).toBe('completed') - - await run.dispose() + try { + await run.result + const child = run.localAgent as Agent + expect(await readFile(allowed, 'utf8')).toBe('fine') + expect(child.session.events.some( + event => event.type === 'sandbox/mode' || event.type === 'approval/policy', + )).toBe(false) + expect(child.session.firstLiveSeq).toBe(0) + } finally { + await run.dispose() + } }) }) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index e7ff3fa2a7..c29bf60910 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/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: 2d01844d8391a530ec06878c0b77b20bf74d6f12 -README.zh.md: 4456f53291b64ce14a6eaea5204050ecfc043560 +README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa +README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 2d01844d83..6a59ad9425 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. Both it and `current(session)` resolve the knobs through the same override chains execution reads (`sandboxOverrideOf`/`approvalOverrideOf`: own post-seed switches, else the inherited header baseline, else composition defaults), so a delegated child inheriting a wider baseline gets real knob switches when a narrower preset is selected, and a seed-carried selection is subsumed by the baseline. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` prefers a still-matching recorded own selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 4456f53291..0e7db1bd41 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -4,7 +4,7 @@ 通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。它与 `current(session)` 都通过执行所读取的同一套覆盖链解析调节项(`sandboxOverrideOf`/`approvalOverrideOf`:先取会话自己在种子之后的切换,否则取会话头中继承的基线,否则取组合默认值),因此继承了更宽基线的被委派子 agent(智能体)在选中更窄的 preset 时会得到真实的调节项切换,而种子携带的选择会被基线所涵盖。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 优先返回仍与当前调节项匹配的、会话自己的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。 -- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent/profile 选择尚未纳入 `PresetSpec`。 +- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 616a92c3f3..d44dff3df4 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -12,12 +12,12 @@ 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, sandboxOverrideOf, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.bash` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import { APPROVAL_POLICIES, approvalOverrideOf, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' declare module 'cordis' { interface Context { @@ -139,24 +139,17 @@ export class PermissionService extends Service { } /** - * Resolve the preset matching the effective knob values — the same - * override chains execution reads (own post-seed switches, else the - * inherited header baseline, else the composition defaults), so a - * delegated child's inherited knobs derive its real preset. A - * still-matching last selection wins shared-bundle ties, scoped like the - * knob chains: a delegation child (header baselines present) ignores - * seed-carried selections as stale parent history, while a generic fork - * child keeps them alongside its seed-carried knobs; otherwise the first - * table match wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param session - the session whose preset to derive. + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. * @returns the effective preset name, or `custom` when nothing matches. */ - current(session: Session): string { - const sandbox = sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode - const approval = approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask' + 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 delegated = session.header.sandboxMode !== undefined || session.header.approvalPolicy !== undefined - const folded = effectivePermissionPreset(delegated ? session.events.slice(session.header.seedLength ?? 0) : session.events) + const folded = effectivePermissionPreset(events) if (folded !== undefined) { const spec = this.presets[folded] if (spec !== undefined && matches(spec)) return folded @@ -204,17 +197,14 @@ export class PermissionService extends Service { */ set(session: Session, name: string): void { const spec = this.resolve(name) - if (this.current(session) !== name) { + if (this.current(session.events) !== name) { session.append('permission/preset', { preset: name }) } - // Compare against the SAME override chains current() derives from: a - // child inheriting a wider baseline must get real knob switches when the - // user selects a narrower preset — an event-only fold would believe the - // preset is already active and silently leave enforcement at the baseline. - if (spec.sandbox !== (sandboxOverrideOf(session) ?? this.ctx.bash.sandboxMode)) { + const events = session.events + if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (approvalOverrideOf(session) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { setApprovalPolicy(session, spec.approval) } } diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 4133ee7bbf..864f25629d 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -48,25 +48,25 @@ describe('PermissionService', () => { 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)).toBe('workspace-write') + expect(ctx.permission.current(session.events)).toBe('workspace-write') ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session)).toBe('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 () => { const ctx = await mounted() const session = freshSession('sess-custom') session.append('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permission.current(session)).toBe(CUSTOM_PRESET) + expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) ctx.permission.set(session, 'danger-full-access') - expect(ctx.permission.current(session)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') 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)).toBe(CUSTOM_PRESET) + 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 () => { @@ -77,10 +77,10 @@ describe('PermissionService', () => { } } }) const session = freshSession('sess-tie') ctx.permission.set(session, 'agentish') - expect(ctx.permission.current(session)).toBe('agentish') + expect(ctx.permission.current(session.events)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permission.current(session)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -140,47 +140,6 @@ describe('PermissionService', () => { const session = freshSession('sess-standin') ctx.permission.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permission.current(session)).toBe('workspace-write') - }) - - it('derives current() from an inherited header baseline and switches AWAY from it for real', async () => { - const ctx = await mounted() - // A delegated child: danger-full-access baseline over the composition's - // workspace-write/ask defaults — the child header, not the event log, - // carries the effective knobs. - const id = SessionId('sess-inherited-preset') - const child = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - sandboxMode: 'danger-full-access', - approvalPolicy: 'never', - }) - expect(ctx.permission.current(child)).toBe('danger-full-access') - - // Selecting workspace-write must APPEND both knob switches: folding only - // events would believe workspace-write is already active and silently - // leave enforcement at the inherited danger-full-access. - ctx.permission.set(child, 'workspace-write') - expect(child.events.some(e => e.type === 'sandbox/mode' && e.data.mode === 'workspace-write')).toBe(true) - expect(child.events.some(e => e.type === 'approval/policy' && e.data.policy === 'ask')).toBe(true) - expect(ctx.permission.current(child)).toBe('workspace-write') - }) - - it('ignores a seed-carried preset selection in favor of the delegation baseline', async () => { - const ctx = await mounted() - const id = SessionId('sess-seeded-preset') - const seeded = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - sandboxMode: 'danger-full-access', - approvalPolicy: 'never', - seedLength: 1, - }) - // The fork seed carried the PARENT's old selection event; the baseline - // captured after it owns the child's truth. - seeded.append('permission/preset', { preset: 'workspace-write' }) - expect(ctx.permission.current(seeded)).toBe('danger-full-access') + expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) diff --git a/packages/ui/user-approval/README.i18n.yaml b/packages/ui/user-approval/README.i18n.yaml index 0b80c98b63..083bc84d20 100644 --- a/packages/ui/user-approval/README.i18n.yaml +++ b/packages/ui/user-approval/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md -README.md: c29ada50950cb6b40065a09bac1becbf4afb0ce4 -README.zh.md: c119f2009298edfaa101b10c7562d0c36324ec5d +# pnpm run verify-translation-pairing --write +README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64 +README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782 diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index c29ada5095..38bcfbfe81 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the session's override chain (`approvalOverrideOf`, below), falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed positionally over the session's OWN events: to the user when an own override follows the last own `request/header`, to the delegating session when no own override exists and the delta matches the inherited header baseline, and to operator/config otherwise. `ctx.approval.overrideOf(session)` (the pure `approvalOverrideOf` export, also consumed by the permission presets) resolves the session's override chain, never the configured default: with an inherited `approvalPolicy` header baseline (a delegation child), the fold of the session's OWN switches past `SessionHeader.seedLength`, else the baseline, validated against the closed vocabulary on read; without one (a top-level session or a generic `SessionStore.fork` child), the whole-log fold, so a seed-carried `'never'` survives; the in-process subagent driver captures this at delegation and writes it into each child's creation-time header, so a `'never'` parent cannot mint prompting children, with no first-turn timing window ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). @@ -18,7 +18,7 @@ The tools pipeline routes `ask` decisions through this seam and fails closed whe #### What the model sees -Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).`, `The approval policy changed from "" to "" (inherited from the delegating session).`, or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. +Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "" to "" (changed by the user).` or `The approval policy changed from "" to "" (changed by the operator/config).` before the next step. ##### Ask-policy prompt section diff --git a/packages/ui/user-approval/README.zh.md b/packages/ui/user-approval/README.zh.md index c119f20092..2a3a6d08d6 100644 --- a/packages/ui/user-approval/README.zh.md +++ b/packages/ui/user-approval/README.zh.md @@ -8,7 +8,7 @@ 应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答所拥有 agent 的请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终端应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其拥有的会话提供一次性机器决定。 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取会话的覆盖链(见下文 `approvalOverrideOf`),并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知,并按其在会话自己的事件中的位置归因:如果会话自己的覆盖出现在自己最后一个 `request/header` 之后,则归因于用户;如果不存在自己的覆盖且该变化与继承的会话头基线相符,则归因于发起委派的会话;否则归因于操作方/配置。`ctx.approval.overrideOf(session)`(即纯函数导出 `approvalOverrideOf`,也供权限 preset 消费)解析会话的覆盖链,绝不包含配置默认值:当存在继承的 `approvalPolicy` 会话头基线时(即委派子 agent),先折叠会话自己在 `SessionHeader.seedLength` 之后的切换,否则取该基线,读取时按封闭词汇校验;没有基线时(顶层会话或通用的 `SessionStore.fork` 子会话),折叠覆盖完整日志,因此种子携带的 `'never'` 得以存续;进程内 subagent 驱动器在委派时捕获该值,并写入每个子 agent 创建时的会话头,使 `'never'` 父级无法造出会弹出提示的子 agent,且不存在任何第一轮次的时序窗口(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。 +`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。 工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 @@ -18,7 +18,7 @@ #### 模型看到的内容 -在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).`、`The approval policy changed from "" to "" (inherited from the delegating session).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 +在 `ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "" to "" (changed by the user).` 或 `The approval policy changed from "" to "" (changed by the operator/config).`。 ##### Ask 策略提示词段 diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 644cfd9153..e4f60da037 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -59,13 +59,16 @@ declare module '@deepseek-ai/dsh-session' { /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy - * from the prompt section and the narrator's notices). The last such OWN - * (post-seed) event is the session's override - * ({@link approvalOverrideOf}); who asked for it is derivable from - * position (an own event after the log's last own `request/header` was a - * runtime switch by the user). + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}). + * `source: 'delegation'` marks an override seeded into a child; an absent + * source is a runtime switch. */ - 'approval/policy': { policy: ApprovalPolicy } + 'approval/policy': { + policy: ApprovalPolicy + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' + } } } @@ -124,12 +127,10 @@ function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefi } /** - * The pure fold of a slice of `approval/policy` events: the last switch - * wins, or undefined without one. The building block - * {@link approvalOverrideOf} composes with the seed boundary and the header - * baseline — consumers resolving a SESSION's policy go through that chain, - * not this raw fold. Resume needs no catch-up machinery because replaying - * the log IS the state. + * The session's approval-policy override: the last `approval/policy` 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 policy of the last switch event, or undefined without one. */ @@ -141,41 +142,6 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } -/** - * The session's complete approval-policy OVERRIDE chain — the one home every - * consumer (this service's policy tier, the permission presets) resolves - * through. With a header baseline (a delegation child), the fold covers only - * the session's OWN switches past the seed boundary — the baseline was - * captured from the parent's FULL log at delegation, so any seed-carried - * switch is already subsumed by it. Without a baseline (a top-level session, - * or a generic `SessionStore.fork` child that captured no policy meta), the - * fold covers the whole log: seeded switches ARE the replayed inherited - * truth, and slicing them away would silently drop a forked `'never'`. Never - * the configured default itself. The durable baseline is validated - * UNCONDITIONALLY — a corrupt or foreign header must fail loud on every - * read, not only when no own switch happens to shadow it. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the configured default. - * @throws when the header baseline is outside the closed policy vocabulary. - */ -export function approvalOverrideOf(session: Session): ApprovalPolicy | undefined { - const baseline = session.header.approvalPolicy - if (baseline === undefined) return effectiveApprovalPolicy(session.events) - if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) { - throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`) - } - // A boundary past the log would make the own-switch slice empty until the - // log grows past it — a baseline would then shadow a REAL later switch. - // Malformed durable metadata fails loud, never fails open. - const seedLength = session.header.seedLength ?? 0 - if (seedLength > session.events.length) { - throw new Error(`session header seedLength ${seedLength} exceeds the log length ${session.events.length}`) - } - const own = effectiveApprovalPolicy(session.events.slice(seedLength)) - return own ?? baseline as ApprovalPolicy -} - /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -279,30 +245,28 @@ export class ApprovalService extends Service { // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told // is in-memory with a log-derived fallback (the folded header's system - // text), so restarts lose nothing. Attribution is positional over the - // session's OWN events (past the seed boundary — a seed-carried switch is - // stale parent history, never this session's runtime action): an own - // override after the last own `request/header` was a runtime switch by - // the user; no own override with the delta matching the inherited header - // baseline came from the delegating session; otherwise the configured - // default moved under the session (operator/config). + // text), so restarts lose nothing. Attribution is positional: an + // override event after the log's last `request/header` was a runtime + // switch by the user; otherwise the configured default moved under the + // session (operator/config). const narrated = new WeakMap() ctx.on('agent/step', (agent) => { const session = agent.session const events = session.events - const seedStart = Math.min(session.header.seedLength ?? 0, events.length) let overrideIndex = -1 + let overrideSource: 'delegation' | undefined let headerIndex = -1 - for (let index = events.length - 1; index >= seedStart && (overrideIndex < 0 || headerIndex < 0); index -= 1) { + for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) { const event = events[index] as (typeof events)[number] if (overrideIndex < 0 && event.type === 'approval/policy') { overrideIndex = index + overrideSource = event.data.source } else if (headerIndex < 0 && event.type === 'request/header') { headerIndex = index } } - // Same fold effectivePolicy performs — the own override is scanned here - // anyway for POSITIONAL attribution; the default lives once, in the method. + // Same fold effectivePolicy performs — override is scanned here anyway + // for POSITIONAL attribution; the default lives once, in the method. const current = this.effectivePolicy(session) const header = session.requestHeader() const told = narrated.get(session) ?? toldApprovalPolicy(header?.system) @@ -310,11 +274,9 @@ export class ApprovalService extends Service { // Cold start (nothing ever told) narrates nothing — the section about // to go out states the truth, and there is no delta to explain. if (told === undefined || told === current) return - const cause = overrideIndex > headerIndex - ? 'changed by the user' - : overrideIndex < 0 && session.header.approvalPolicy === current - ? 'inherited from the delegating session' - : 'changed by the operator/config' + const cause = overrideSource === 'delegation' + ? 'inherited from the delegating session' + : overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config' agent.inject(createUserMessage({ content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }], source: { kind: 'plugin', plugin: 'user-approval' }, @@ -362,9 +324,9 @@ export class ApprovalService extends Service { } /** - * The session's effective policy: its override chain ({@link overrideOf}), - * else the configured default (the schema already defaulted an omitted - * policy to `'ask'`; the `??` only narrows the optional-input TYPE). + * The session's effective policy: its own `approval/policy` fold, else the + * configured default (the schema already defaulted an omitted policy to + * `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. * @returns the policy every ask for this session resolves under right now. */ @@ -373,15 +335,12 @@ export class ApprovalService extends Service { } /** - * {@link approvalOverrideOf} surfaced on the service, for consumers that - * reach the seam through `ctx.get('approval')` (the subagent driver's - * delegation capture) rather than a value import. - * @param session - the session whose override chain to resolve. - * @returns the effective override, or `undefined` for a session following - * the configured default. + * Read the session override without applying the configured default. + * @param session - session whose log supplies the override. + * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return approvalOverrideOf(session) + return effectiveApprovalPolicy(session.events) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 875e284807..0d586049fa 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -20,9 +20,6 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty const agent = { session: { events: seed, - // The typed Session contract the service folds over includes the header - // (seed boundary + inherited baselines); the stub carries a bare one. - header: { version: 0, id: 'fake-session', createdAt: 0 }, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -459,7 +456,9 @@ describe('approval policy (the approval/policy fold)', () => { await ctx.plugin(ApprovalService, { policy: 'never' }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const { agent, session } = sessionAgent('sess-gate-3') + expect(ctx.approval.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'ask') + expect(ctx.approval.overrideOf(session)).toBe('ask') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once') setApprovalPolicy(session, 'never') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') @@ -510,6 +509,18 @@ describe('approval policy (the approval/policy fold)', () => { expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).']) }) + it('attributes a constructor-seeded policy event to delegation', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session, injected } = sessionAgent('sess-narr-inherited') + appendHeader(session, ASK_MARKER) + session.append('approval/policy', { policy: 'never', source: 'delegation' }) + + await preStep(ctx, agent) + + expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) + }) + it('narrates a config default drift from the logged ask marker', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) @@ -519,38 +530,6 @@ describe('approval policy (the approval/policy fold)', () => { expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).']) }) - it('does not attribute a fork child\'s baseline delta to a stale seed-carried user switch', async () => { - // A fork child: the seed carries the parent's OLD 'ask' switch (event 0, - // inside seedLength) and the last request header told 'ask'; the header - // baseline captured at delegation is 'never'. The delta must not be - // attributed to "the user" — the seed switch is stale parent history, not - // this session's runtime action. - const ctx = new Context() - await ctx.plugin(ApprovalService) - const id = SessionId('sess-narr-fork-baseline') - const session = new Session(id, undefined, { - version: 0, - id, - createdAt: 0, - approvalPolicy: 'never', - seedLength: 2, - }) - setApprovalPolicy(session, 'ask') - session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: `persona\n${ASK_MARKER}` }, reason: 'initial' }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const injected: string[] = [] - const agent = { - id, - session, - inject: (input: { content: Array<{ type: string; text: string }> }) => { - injected.push(input.content[0]?.text ?? '') - }, - } as unknown as Agent - - await preStep(ctx, agent) - expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) - }) - it('a pinned override survives a default change silently', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) @@ -613,88 +592,3 @@ describe('approval policy (the approval/policy fold)', () => { expect(afterDispose.injected).toEqual([]) }) }) - -describe('delegation inheritance (overrideOf over the header baseline)', () => { - function bareSession(id: string): Session { - return new Session(SessionId(id)) - } - - /** A session whose header carries the delegation-inheritance baseline. */ - function inheritedSession(id: string, meta: { approvalPolicy?: string; seedLength?: number } = {}): Session { - const sessionId = SessionId(id) - return new Session(sessionId, undefined, { - version: 0, - id: sessionId, - createdAt: 0, - ...meta.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy }, - ...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength }, - }) - } - - it('overrideOf folds the session log and never falls back to the configured default', async () => { - const ctx = await mounted() - const parent = bareSession('sess-appr-inherit-parent') - setApprovalPolicy(parent, 'never') - - expect(ctx.approval.overrideOf(parent)).toBe('never') - expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined() - }) - - it('overrideOf reads the header baseline when the log has no own switch, and effectivePolicy follows', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-baseline', { approvalPolicy: 'never' }) - - expect(ctx.approval.overrideOf(child)).toBe('never') - // The request path consumes the same chain: an inherited 'never' rejects - // deterministically before any answerer could run. - child.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const agent = { session: child } as unknown as Agent - await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('rejected') - }) - - it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-slice', { approvalPolicy: 'never', seedLength: 1 }) - // Event 0 sits inside the seed boundary — stale parent history, subsumed - // by the delegation-time baseline. - setApprovalPolicy(child, 'ask') - expect(ctx.approval.overrideOf(child)).toBe('never') - // Event 1 is the child's OWN switch — it outranks the baseline. - setApprovalPolicy(child, 'ask') - expect(ctx.approval.overrideOf(child)).toBe('ask') - }) - - it('rejects a header baseline outside the closed policy vocabulary (durable boundary)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-invalid', { approvalPolicy: 'always' }) - - expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) - }) - - it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-invalid-own', { approvalPolicy: 'always' }) - setApprovalPolicy(child, 'never') - - expect(() => ctx.approval.overrideOf(child)).toThrow(/approvalPolicy/) - }) - - it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => { - const ctx = await mounted() - // The public fork path sets seedLength but captures no delegation - // baseline; with nothing to subsume them, seeded switches ARE the - // child's inherited truth — slicing would silently drop a forked 'never'. - const child = inheritedSession('sess-appr-generic-fork', { seedLength: 1 }) - setApprovalPolicy(child, 'never') - - expect(ctx.approval.overrideOf(child)).toBe('never') - }) - - it('rejects a seed boundary past the log end instead of silently ignoring own switches', async () => { - const ctx = await mounted() - const child = inheritedSession('sess-appr-oob', { approvalPolicy: 'ask', seedLength: 100 }) - setApprovalPolicy(child, 'never') - - expect(() => ctx.approval.overrideOf(child)).toThrow(/seedLength/) - }) -}) From a70f1a2b7a8afaa87c0eeff0087b58fad892bf09 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:32:10 +0800 Subject: [PATCH 24/61] test(acp): remove vacuous policy inheritance scenario The ACP fixture configured read-only as the deployment default for both parent and child. Its delegated write therefore remained denied even with inheritance disabled, so the scenario could not fail on the regression it claimed to protect. Delete the overlay, scenario registration, sessions, prompt, and 473-line tool-schema sidecar. The Loader-booted headless snapshot remains the real composition guard: only its parent carries read-only while the deployment default is workspace-write, so removing inheritance makes the child write reach disk and fails the test. Keeping one discriminating snapshot avoids 662 lines of duplicated fixture data and makes the review evidence correspond to the actual security boundary. --- .../subagent-inheritance.cordis.snapshot.yml | 45 -- .../acp-agent/subagent-inheritance.cordis.yml | 25 - examples/acp-agent/tests/acp.snapshot.ts | 17 - .../subagent-sandbox-inheritance/input.json | 14 - .../session.1.jsonl | 30 -- .../session.jsonl | 30 -- .../stdout.expected.jsonl | 4 - .../system-prompt.expected.md | 24 - .../tool-schemas.expected.json | 473 ------------------ 9 files changed, 662 deletions(-) delete mode 100644 examples/acp-agent/subagent-inheritance.cordis.snapshot.yml delete mode 100644 examples/acp-agent/subagent-inheritance.cordis.yml delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json diff --git a/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml deleted file mode 100644 index a7a9302822..0000000000 --- a/examples/acp-agent/subagent-inheritance.cordis.snapshot.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Keyless replay counterpart of subagent-inheritance.cordis.yml: the same -# flash pin plus the standard replay swaps (disable the key-requiring adapter, -# passthrough sandbox runner, insert llm-replay). Patches do not compose -# across nested includes, so everything applies together over the live tree. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - config: - runnerCommand: - - bash - - -c - - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" - - passthrough-runner - runnerFailureSignatures: - - 'passthrough-runner: profile rejected' - - id: acp-agent - name: '@deepseek-ai/dsh-acp-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - persistenceCompression: none - workspaceContext: - maxBytes: 65536 - 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. - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek - name: DeepSeek - models: - - id: deepseek-v4-flash - - id: deepseek-v4-pro diff --git a/examples/acp-agent/subagent-inheritance.cordis.yml b/examples/acp-agent/subagent-inheritance.cordis.yml deleted file mode 100644 index 2757d95d1f..0000000000 --- a/examples/acp-agent/subagent-inheritance.cordis.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Subagent-under-confinement snapshot overlay: pin the recorded model to -# deepseek-v4-flash so this scenario's request headers match the recorded -# sandbox-class corpus (cordis.yml ships deepseek-v4-pro for live use). The -# read-only policy itself comes from the scenario's DSH_PERMISSION_MODE env — -# the automation protocol has no session-scoped picker, so deployment policy -# is the lever ([downgrade rationale in the scenario table]). A config patch -# replaces the whole target config, so base fields are restated verbatim. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: acp-agent - name: '@deepseek-ai/dsh-acp-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - workspaceContext: - maxBytes: 65536 - 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.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9f3007b770..a6c91bd5cd 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,7 +38,6 @@ const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml' const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) -const SUBAGENT_INHERITANCE_CONFIG = fileURLToPath(new URL('../subagent-inheritance.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) @@ -264,22 +263,6 @@ const SCENARIOS: Scenario[] = [ headerClass: 'sandbox', env: { DSH_PERMISSION_MODE: 'workspace-write' }, }, - // Delegation under a read-only DEPLOYMENT policy: the child's real write is - // denied by the fs fence and its denial report surfaces in the parent - // transcript. This is the strength the automation protocol can express — no - // session-scoped picker exists to tighten only the parent, so the - // parent-override-vs-wider-default half of the inheritance contract stays - // pinned by the real-wall unit suite (the subagent policy-inheritance Agent - // Note records the downgrade). - { - name: 'subagent-sandbox-inheritance', - hasModelTurn: true, - recorded: true, - pinsHeader: true, - headerClass: 'subagent-inheritance', - configPath: SUBAGENT_INHERITANCE_CONFIG, - env: { DSH_PERMISSION_MODE: 'read-only' }, - }, // Unlike ordinary snapshots, this session cwd is outside the platform temp // roots that workspace-write always grants. The overlay points the // deployment fallback at /tmp, so a successful relative write proves the diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json deleted file mode 100644 index d0be3ce419..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/input.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "steps": [ - { - "op": "initialize" - }, - { - "op": "newSession" - }, - { - "op": "prompt", - "text": "Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation." - } - ] -} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl deleted file mode 100644 index 577d03330d..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.1.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"7fcdaf99-35c9-4ad6-a872-fc04fbfe4ee6","createdAt":1784961244926,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-XKPvGB","parentSession":"03d6a514-045b-4f61-9a8f-1b5165c3a648","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784961244928,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784961244928,"data":{"content":[{"type":"text","text":"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \"inherited.txt\" (never an absolute path) and content set to \"Child agent wrote this file.\" (escaped as needed). \n\nIf the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\n\nIf the write succeeds, reply with the single word CHILD_WROTE.\n\nDo not use any other tools or do anything else."}],"source":{"kind":"user"},"role":"user","id":"794cb9e6-5770-40bf-a0c6-69da4a71fe01"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784961244929,"data":{"title":"You have access to the","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784961244932,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784961244933,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784961245908,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784961245909,"data":{"turn":1,"step":1,"index":0,"dt":[145,8,0,1,0,46,1,0,0,16,1,0,30,1,32,1,0,0,0,1,29,0,1,34,0,1,0,30,1,30,0,0,32,33,1,0,0,1,0,28,33,1,0,0,33,0,1,0,0,31,0,1,0,0,30,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," write"," tool"," exactly"," once"," with"," file","_path","=\"","inher","ited",".txt","\""," and"," content","=\"","Child"," agent"," wrote"," this"," file",".\"."," If"," denied",","," I"," should"," reply"," with"," CH","ILD","_D","EN","IED"," followed"," by"," the"," denial"," marker","."," If"," successful",","," reply"," with"," CH","ILD","_W","RO","TE","."]}} -{"type":"assistant/chunk","seq":63,"time":1784961246638,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":64,"time0":1784961246639,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,31,1,0,0,31,1,0,0,1,63,1,0,0,1,32,1,0,0,0,1,32,0,1],"id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","args":["","{","\"","file","_path","\"",": ","\"","inher","ited",".txt","\"",", ","\"","content","\"",": ","\"","Child"," agent"," wrote"," this"," file",".","\"","}"]}} -{"type":"assistant/chunk","seq":90,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path=\"inherited.txt\" and content=\"Child agent wrote this file.\". If denied, I should reply with CHILD_DENIED followed by the denial marker. If successful, reply with CHILD_WROTE."}}}} -{"type":"assistant/chunk","seq":91,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}}}} -{"type":"assistant/chunk","seq":92,"time":1784961246903,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5377,"outputTokens":123,"cacheReadTokens":0,"reasoningTokens":57}}}} -{"type":"assistant/chunk","seq":93,"time":1784961246904,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":94,"time":1784961246905,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the write tool exactly once with file_path=\"inherited.txt\" and content=\"Child agent wrote this file.\". If denied, I should reply with CHILD_DENIED followed by the denial marker. If successful, reply with CHILD_WROTE."},{"type":"tool-call","id":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"aacd17fa-e534-4a19-8e8a-db123fc0b7b1"},"usage":{"inputTokens":5377,"outputTokens":123,"cacheReadTokens":0,"reasoningTokens":57}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"tool/call","seq":95,"time":1784961246905,"data":{"turn":1,"step":1,"callId":"call_00_7iJutQqZ95RcVbXUefTC5135","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"Child agent wrote this file.\"}"}} -{"type":"tool/result","seq":96,"time":1784961246918,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_7iJutQqZ95RcVbXUefTC5135"},"content":[{"type":"tool-result","toolCallId":"call_00_7iJutQqZ95RcVbXUefTC5135","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"2c6046db-7862-4010-b614-4fd09850bedb"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[95],"surfaceOp":"append"} -{"type":"step/end","seq":97,"time":1784961246924,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":98,"time":1784961246925,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":99,"time":1784961247934,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":100,"time0":1784961247934,"data":{"turn":1,"step":2,"index":0,"dt":[119,43,2,1,0,20,0,0,1,0,33,1,0,0,0,0,36,0,0,1,32,2,1,0,0,0,25,0,0,0,1,36,1,0,0,0,25,1,0,0,0,0,31,1,0,0,0,34,1,0,1,0,0,33,0,0,1,32,1,0,0,0,32,0,1,0,0,35,0,0,0,0,1,32,1,0,0,0,1,32,38,0,0,1,0,29,1,0,0,1,0,30,0,0,1,0,32,0,0,1,36,1,28,1,0,0,0,1,34,1,0,0,0,0,29,1,0,0,0],"texts":["The"," write"," was"," denied"," by"," the"," sand","box"," under"," read","-only"," mode","."," According"," to"," the"," instructions",":"," \"","If"," the"," write"," is"," denied"," by"," the"," sand","box"," (","look"," for"," \"[","sand","box",":"," file"," access"," denied","\""," in"," the"," result","),"," do"," NOT"," ret","ry"," and"," do"," NOT"," request"," escalation","."," Reply"," with"," the"," single"," word"," CH","ILD","_D","EN","IED"," followed"," by"," a"," space"," and"," then"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result",".\"\n\n","The"," denial"," marker"," line"," is",":"," \"[","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","\"\n\n","So"," I"," should"," reply",":"," CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]"]}} -{"type":"assistant/chunk","seq":220,"time":1784961248883,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":221,"time0":1784961248884,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,30,1,0,0,0,0,35,0,0,0,1],"texts":["CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]"]}} -{"type":"assistant/chunk","seq":238,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied by the sandbox under read-only mode. According to the instructions: \"If the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\"\n\nThe denial marker line is: \"[sandbox: file access denied under read-only mode]\"\n\nSo I should reply: CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":239,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":240,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":138,"cacheReadTokens":5376,"reasoningTokens":120}}}} -{"type":"assistant/chunk","seq":241,"time":1784961248951,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":242,"time":1784961248952,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The write was denied by the sandbox under read-only mode. According to the instructions: \"If the write is denied by the sandbox (look for \"[sandbox: file access denied\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\"\n\nThe denial marker line is: \"[sandbox: file access denied under read-only mode]\"\n\nSo I should reply: CHILD_DENIED [sandbox: file access denied under read-only mode]"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7fb28534-4a44-435d-8d15-403f7ee1bebd"},"usage":{"inputTokens":188,"outputTokens":138,"cacheReadTokens":5376,"reasoningTokens":120}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241],"surfaceOp":"append"} -{"type":"step/end","seq":243,"time":1784961248963,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":244,"time":1784961248963,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl deleted file mode 100644 index 383e179fe5..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/session.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"03d6a514-045b-4f61-9a8f-1b5165c3a648","createdAt":1784961240155,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-XKPvGB","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784961240159,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784961240159,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Use the write tool exactly once with file_path set to exactly the relative path inherited.txt (never an absolute path) and content escaped. If the write is denied, do not retry and do not request escalation; reply with the single word CHILD_DENIED and the exact denial marker line from the tool result. If it succeeds, reply with the single word CHILD_WROTE.' After the subagent returns, state in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with the single word PARENT_DONE and stop. Do not use the bash or write tools yourself and do not request escalation."}],"source":{"kind":"user"},"role":"user","id":"6ac76657-3b3d-4661-ac5f-973dbf9a8b82"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1784961240160,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1784961240164,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1784961240164,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1784961241327,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1784961241328,"data":{"turn":1,"step":1,"index":0,"dt":[11,32,1,0,0,0,0,32,0,0,1,0,0,57,1,2,0,33,0,0,44,0,0,29,1,31,26,1,0,0,0,40,31,1,0,0,25,1,0,45,19,0,0,1,0,0,41,1,0,0,0,22,1,0,0,0,0,33,0,0,1,30,0,0,0,0,33,1,0,0,0,49,0,0,1,0,17,1,0,0,0,0,28,1,0,0,0,0,39,0,1,0,0,30,1,0,26,1,0,0,31,1,0,41,1,0,0,0,0,25,0,0,0,34,1,0,0,0,0,39,0,31,0,0,0,0,0,30,1,0,0,0,30,1,24,0,0,1,0,0,44,1,18,2,1,0,0,0,31,1,0,0,0,30,39,1,0,0,0,1,27,1,0,0,0,0,29,1,0,0,0,0,37,1,0,0,26,1,0,32,1,0,31,1,0,0,33,0,0,33,1,0,0,31,1,0,1,30,1,0,33,1,0,0,30,33,31,1,32,1,0,1,30,33,0,1],"texts":["The"," user"," wants"," me"," to"," delegate"," a"," subt","ask"," to"," a"," sub","agent","."," The"," subt","ask"," is",":"," use"," the"," write"," tool"," to"," write"," to"," `","inher","ited",".txt","`"," with"," content"," escaped","."," If"," denied",","," reply"," with"," CH","ILD","_D","EN","IED"," and"," the"," denial"," marker","."," If"," succeeds",","," reply"," with"," CH","ILD","_W","RO","TE",".\n\n","After"," the"," sub","agent"," returns",","," I"," need"," to"," state"," whether"," the"," child"," was"," denied"," by"," the"," sand","box",","," quoting"," the"," denial"," marker"," if"," there"," is"," one",","," then"," reply"," with"," PAR","ENT","_D","ONE",".\n\n","Let"," me"," carefully"," construct"," the"," sub","agent"," prompt",".\n\n","The"," sub","agent"," should",":\n","1","."," Use"," the"," write"," tool"," with"," file","_path"," ="," \"","inher","ited",".txt","\""," (","relative"," path",")\n","2","."," If"," the"," write"," is"," denied",","," reply"," with"," \"","CH","ILD","_D","EN","IED","\""," and"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result","\n","3","."," If"," it"," succeeds",","," reply"," with"," \"","CH","ILD","_W","RO","TE","\"\n\n","I"," need"," to"," make"," sure"," the"," sub","agent"," prompt"," is"," self","-contained"," and"," clear","."," The"," sub","agent"," doesn","'t"," share"," this"," conversation"," context",","," so"," I"," need"," to"," explain"," what"," tools"," it"," has"," available",".\n\n","Let"," me"," craft"," the"," prompt"," carefully","."]}} -{"type":"assistant/chunk","seq":223,"time":1784961243256,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":224,"time0":1784961243257,"data":{"turn":1,"step":1,"index":1,"dt":[31,1,0,0,0,31,0,1,0,31,34,31,1,0,0,0,32,2,0,0,30,0,1,31,1,0,0,32,0,31,1,0,0,0,34,0,1,0,30,1,0,0,1,0,32,1,0,0,1,0,31,1,0,31,1,32,1,32,1,33,1,41,1,0,23,35,0,34,29,1,42,1,1,0,21,1,34,1,0,0,33,31,1,32,0,0,1,31,1,0,31,0,0,33,1,0,0,0,1,31,1,0,0,31,0,1,38,1,0,25,0,0,33,1,1,32,1,32,1,0,1,39,0,1,0,25,1,0,0,1,30,1,0,0,0,32,1,0,0,0,0,31,0,0,33,1,0,0,0,33,0,0,43,0,25,0,0,0,30],"id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","args":["","{","\"","description","\"",": ","\"","Write"," inherited",".txt"," task","\"",", ","\"","prom","pt","\"",": ","\"","You"," have"," access"," to"," the"," write"," tool","."," Use"," the"," write"," tool"," exactly"," once"," with"," file","_path"," set"," to"," exactly"," the"," relative"," path"," \\\"","inher","ited",".txt","\\\""," (","never"," an"," absolute"," path",")"," and"," content"," set"," to"," \\\"","Child"," agent"," wrote"," this"," file",".\\\""," (","esc","aped"," as"," needed",")."," \\n\\n","If"," the"," write"," is"," denied"," by"," the"," sand","box"," (","look"," for"," \\\"[","sand","box",":"," file"," access"," denied","\\\""," in"," the"," result","),"," do"," NOT"," ret","ry"," and"," do"," NOT"," request"," escalation","."," Reply"," with"," the"," single"," word"," CH","ILD","_D","EN","IED"," followed"," by"," a"," space"," and"," then"," the"," exact"," denial"," marker"," line"," from"," the"," tool"," result",".\\n\\n","If"," the"," write"," succeeds",","," reply"," with"," the"," single"," word"," CH","ILD","_W","RO","TE",".\\n\\n","Do"," not"," use"," any"," other"," tools"," or"," do"," anything"," else",".","\"","}"]}} -{"type":"assistant/chunk","seq":384,"time":1784961244901,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to delegate a subtask to a subagent. The subtask is: use the write tool to write to `inherited.txt` with content escaped. If denied, reply with CHILD_DENIED and the denial marker. If succeeds, reply with CHILD_WROTE.\n\nAfter the subagent returns, I need to state whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with PARENT_DONE.\n\nLet me carefully construct the subagent prompt.\n\nThe subagent should:\n1. Use the write tool with file_path = \"inherited.txt\" (relative path)\n2. If the write is denied, reply with \"CHILD_DENIED\" and the exact denial marker line from the tool result\n3. If it succeeds, reply with \"CHILD_WROTE\"\n\nI need to make sure the subagent prompt is self-contained and clear. The subagent doesn't share this conversation context, so I need to explain what tools it has available.\n\nLet me craft the prompt carefully."}}}} -{"type":"assistant/chunk","seq":385,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}}}} -{"type":"assistant/chunk","seq":386,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5393,"outputTokens":418,"cacheReadTokens":0,"reasoningTokens":217}}}} -{"type":"assistant/chunk","seq":387,"time":1784961244902,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":388,"time":1784961244908,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to delegate a subtask to a subagent. The subtask is: use the write tool to write to `inherited.txt` with content escaped. If denied, reply with CHILD_DENIED and the denial marker. If succeeds, reply with CHILD_WROTE.\n\nAfter the subagent returns, I need to state whether the child was denied by the sandbox, quoting the denial marker if there is one, then reply with PARENT_DONE.\n\nLet me carefully construct the subagent prompt.\n\nThe subagent should:\n1. Use the write tool with file_path = \"inherited.txt\" (relative path)\n2. If the write is denied, reply with \"CHILD_DENIED\" and the exact denial marker line from the tool result\n3. If it succeeds, reply with \"CHILD_WROTE\"\n\nI need to make sure the subagent prompt is self-contained and clear. The subagent doesn't share this conversation context, so I need to explain what tools it has available.\n\nLet me craft the prompt carefully."},{"type":"tool-call","id":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4e48758d-52ba-437d-aa4a-8f0a9b02eb23"},"usage":{"inputTokens":5393,"outputTokens":418,"cacheReadTokens":0,"reasoningTokens":217}},"sourceEventSeqs":[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,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387],"surfaceOp":"append"} -{"type":"tool/call","seq":389,"time":1784961244910,"data":{"turn":1,"step":1,"callId":"call_00_ZydZ4RS0mQwZaEcUr82s4359","name":"subagent","arguments":"{\"description\": \"Write inherited.txt task\", \"prompt\": \"You have access to the write tool. Use the write tool exactly once with file_path set to exactly the relative path \\\"inherited.txt\\\" (never an absolute path) and content set to \\\"Child agent wrote this file.\\\" (escaped as needed). \\n\\nIf the write is denied by the sandbox (look for \\\"[sandbox: file access denied\\\" in the result), do NOT retry and do NOT request escalation. Reply with the single word CHILD_DENIED followed by a space and then the exact denial marker line from the tool result.\\n\\nIf the write succeeds, reply with the single word CHILD_WROTE.\\n\\nDo not use any other tools or do anything else.\"}"}} -{"type":"tool/result","seq":390,"time":1784961248973,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ZydZ4RS0mQwZaEcUr82s4359"},"content":[{"type":"tool-result","toolCallId":"call_00_ZydZ4RS0mQwZaEcUr82s4359","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"65c6c0b4-8566-4e90-9095-2f72c69ce227"}},"sourceEventSeqs":[389],"surfaceOp":"append"} -{"type":"step/end","seq":391,"time":1784961248981,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":392,"time":1784961248982,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":393,"time":1784961249963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":394,"time0":1784961249964,"data":{"turn":1,"step":2,"index":0,"dt":[107,31,1,0,32,0,0,0,1,0,33,0,0,1,0,0,33,1,0,0,0,0,36,1,0,0,29,1,33,1,0,0,0,34,2,0,1,0,32,1,0,0,32,1,0,0,0,34,0,1,0,0,0,35,0,1,0,0,0,31,1,0,0,0,0,33,1,0,0,1,0,32,0,1,0,0,32,1,0,32,1,0,1,0,32],"texts":["The"," sub","agent"," returned"," \"","CH","ILD","_D","EN","IED"," [","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","\"."," So"," the"," write"," was"," denied"," by"," the"," sand","box"," because"," it","'s"," in"," read","-only"," mode",".\n\n","I"," need"," to",":\n","1","."," State"," in"," one"," short"," sentence"," whether"," the"," child"," was"," denied"," by"," the"," sand","box",","," quoting"," the"," denial"," marker"," if"," there"," is"," one",".\n","2","."," Then"," reply"," with"," \"","PAR","ENT","_D","ONE","\".\n\n","Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":480,"time":1784961250679,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":481,"time0":1784961250679,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,33,1,0,0,0,1,32,1,0,0,0,0,32,1,0,1,0,0,33,1,0,0,1,36,1,1],"texts":["The"," child"," was"," denied"," by"," the"," sand","box"," –"," denial"," marker",":"," `","[","sand","box",":"," file"," access"," denied"," under"," read","-only"," mode","]","`.\n\n","PAR","ENT","_D","ONE"]}} -{"type":"assistant/chunk","seq":511,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_DENIED [sandbox: file access denied under read-only mode]\". So the write was denied by the sandbox because it's in read-only mode.\n\nI need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one.\n2. Then reply with \"PARENT_DONE\".\n\nLet me do that."}}}} -{"type":"assistant/chunk","seq":512,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}}}} -{"type":"assistant/chunk","seq":513,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":80,"outputTokens":117,"cacheReadTokens":5760,"reasoningTokens":86}}}} -{"type":"assistant/chunk","seq":514,"time":1784961250854,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":515,"time":1784961250855,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The subagent returned \"CHILD_DENIED [sandbox: file access denied under read-only mode]\". So the write was denied by the sandbox because it's in read-only mode.\n\nI need to:\n1. State in one short sentence whether the child was denied by the sandbox, quoting the denial marker if there is one.\n2. Then reply with \"PARENT_DONE\".\n\nLet me do that."},{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"258d0ae6-5800-4f06-8f3d-d9988b8dedde"},"usage":{"inputTokens":80,"outputTokens":117,"cacheReadTokens":5760,"reasoningTokens":86}},"sourceEventSeqs":[393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514],"surfaceOp":"append"} -{"type":"step/end","seq":516,"time":1784961250868,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":517,"time":1784961250869,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl deleted file mode 100644 index 6f0c6507ad..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/stdout.expected.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The child was denied by the sandbox – denial marker: `[sandbox: file access denied under read-only mode]`.\n\nPARENT_DONE"}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md deleted file mode 100644 index e3437ad61a..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/system-prompt.expected.md +++ /dev/null @@ -1,24 +0,0 @@ -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}}. 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. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - - - -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. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/subagent-sandbox-inheritance/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). 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": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "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." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "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", - "additionalProperties": false, - "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": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).", - "additionalProperties": true, - "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", - "additionalProperties": true, - "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." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "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\": [...]}).", - "additionalProperties": true - } - }, - "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." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation 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 file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} From 84d0d92cfba70e2ebe597194f4593bfbf7e0c626 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:28:38 +0800 Subject: [PATCH 25/61] test(snapshot): normalize temporary workspace paths --- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 2 +- .../goal-snapshots/goal-session/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../snapshots/bash-tool-turn/session.jsonl | 2 +- .../snapshots/both-mode-turn/session.jsonl | 2 +- .../snapshots/cancel-tool-calls/session.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../snapshots/code-mode-turn/session.jsonl | 2 +- .../code-mode-workspace-context/session.jsonl | 6 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../empty-response-retry/session.jsonl | 2 +- .../snapshots/error-finish/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 2 +- .../escalation-rejected/session.jsonl | 2 +- .../tests/snapshots/fs-edit/session.jsonl | 6 +- .../fs-escalation-approved/session.jsonl | 4 +- .../snapshots/fs-policy-reject/session.jsonl | 8 +- .../snapshots/fs-read-window/session.jsonl | 4 +- .../tests/snapshots/fs-read/session.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 6 +- .../tests/snapshots/fs-write/session.jsonl | 4 +- .../hook-cc-posttool-block/session.jsonl | 2 +- .../hook-cc-posttool-context/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-cc-stop-continue/session.jsonl | 2 +- .../hook-codex-posttool-block/session.jsonl | 2 +- .../hook-codex-posttool-context/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-stop-continue/session.jsonl | 2 +- .../snapshots/lsp-definition/session.jsonl | 2 +- .../tests/snapshots/multi-turn/session.jsonl | 2 +- .../snapshots/packed-chunks/session.jsonl | 2 +- .../parallel-tool-calls/session.jsonl | 6 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../snapshots/repeat-tool-guard/session.jsonl | 2 +- .../session-query-spill/session.jsonl | 2 +- .../session-title-after-turn/session.jsonl | 2 +- .../tests/snapshots/skill-load/session.jsonl | 4 +- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 2 +- .../snapshots/subagent-fork/session.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.2.jsonl | 2 +- .../snapshots/subagent-mixed/session.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 2 +- .../snapshots/subagent-multi/session.2.jsonl | 2 +- .../snapshots/subagent-multi/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 2 +- .../snapshots/subagent-spawn/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../tests/snapshots/todo-write/session.jsonl | 2 +- .../snapshots/tool-call-turn/session.jsonl | 2 +- .../tests/snapshots/web-fetch/session.jsonl | 2 +- .../snapshots/workflow-run/session.1.jsonl | 2 +- .../snapshots/workflow-run/session.jsonl | 2 +- .../snapshots/workspace-context/session.jsonl | 4 +- .../snapshots/workspace-edit/session.jsonl | 4 +- .../headless-agent/tests/headless.snapshot.ts | 9 +- .../advanced-toolchain/session.1.jsonl | 4 +- .../advanced-toolchain/session.2.jsonl | 4 +- .../advanced-toolchain/session.jsonl | 4 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../snapshots/ralph-loop/session.1.jsonl | 2 +- .../snapshots/ralph-loop/session.2.jsonl | 2 +- .../tests/snapshots/ralph-loop/session.jsonl | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 12 +- .../tests/snapshots/bash-tool/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 2 +- .../snapshots/subagent-spawn/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../bash-terminal-card/session.jsonl | 2 +- .../code-mode-dispatch-spill/session.jsonl | 4 +- .../tests/snapshots/code-mode/session.jsonl | 2 +- .../cordis-dynamic-toolchain/session.1.jsonl | 2 +- .../cordis-dynamic-toolchain/session.2.jsonl | 2 +- .../cordis-dynamic-toolchain/session.jsonl | 2 +- .../dynamic-workflow/session.1.jsonl | 2 +- .../snapshots/dynamic-workflow/session.jsonl | 2 +- .../multi-turn-conversation/session.jsonl | 2 +- .../tests/snapshots/todo-plan/session.jsonl | 2 +- examples/tui-agent/tests/tui.snapshot.ts | 12 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/acp-snapshot/README.zh.md | 4 +- packages/support/acp-snapshot/src/index.ts | 1 + .../support/acp-snapshot/src/normalize.ts | 122 +++++++++++++++++- packages/support/acp-snapshot/src/suite.ts | 12 +- .../acp-snapshot/tests/normalize.spec.ts | 49 +++++++ .../support/acp-snapshot/tests/suite.spec.ts | 7 +- 99 files changed, 323 insertions(+), 135 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 48850b2a53..0273e29aae 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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 .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db -2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9 +2026-06-19-acp-snapshot-tests.md: fd600830e36f70d74746cdaf01e762ff9ad4123b +2026-06-19-acp-snapshot-tests.zh.md: c444f8f7e44fd34ee54871d486f4b44aa0fd68c3 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index ba46682b30..fd600830e3 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ A snapshot run asserts **two** normalized surfaces, because the harness's extern The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. -Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout expected output; normalized session equality never overwrites the replay fixture. +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated temporary workspace as `{{tmpdir}}/` in the replay fixture, including filesystem-resolved aliases, so one fixture is portable across platform temp roots; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. ### Isolation: normalization now, sandbox later diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 58195c4788..c444f8f7e4 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -57,7 +57,7 @@ Status: implemented 两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。 -规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的临时 workspace 存储为 `{{tmpdir}}/`,包括文件系统解析出的别名,从而让同一 fixture 可在采用不同临时根目录的平台间移植;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 ### 隔离:当前靠归一化,后续可加沙箱 diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl index c8da831f95..80b225c03f 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"/tmp/goal-session-placeholder"} +{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"{{tmpdir}}/goal-session-placeholder"} 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 1add24f9e4..0d58cbde53 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"64837546-93f0-46bd-83ec-2649c2497663"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 2fd6a59148..b01355ce3c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"043ede8b-08c4-4148-8bca-e2e82337c799"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 464bf95da6..cace7471f1 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-acp","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"4e4ce615-aa57-45de-8dd5-971a72d988ac"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 66db912c04..8d882d3e5d 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f4bbe58d-7866-403f-a9ea-c7f8f7d4b103"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl index 32d20f3d98..de67b40494 100644 --- a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{tmpdir}}/acp-snap-cwd-mrFUuk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"798335c8-fbbf-4eef-a5af-de47d230b7eb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352050753,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 05c2bc0213..da8ad99604 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"/tmp/acp-snap-cwd-gRpiz3","delegationDepth":0} +{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"{{tmpdir}}/acp-snap-cwd-gRpiz3","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014504349,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014504350,"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"},"role":"user","id":"87f8c6e9-fdbb-4b1a-b94d-f155aae58149"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014504359,"data":{"title":"Call the run_code tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 65a6a7f57c..150df245be 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"},"role":"user","id":"37d9d206-cab7-450f-bff6-63a2dddd5f61"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784437195072,"data":{"title":"Run two shell commands: wait","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 2d3039eab9..099e310fc3 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"},"role":"user","id":"f91a282f-c2ba-4759-a3ac-fc24d5db909b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Start a long task; this","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 2e76031380..c10fa03521 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"/tmp/acp-snap-cwd-as7fsu","delegationDepth":0} +{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"{{tmpdir}}/acp-snap-cwd-as7fsu","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014439576,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014439577,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"41779665-2808-4d84-a0a6-0ee5cb76fb06"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014439584,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 3e82feaa13..c3209b50e6 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"/tmp/acp-snap-cwd-muJYhO","delegationDepth":0} +{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"{{tmpdir}}/acp-snap-cwd-muJYhO","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"6d0020b8-1a0e-489d-a2a2-7e820a403324"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,8 +16,8 @@ {"type":"assistant/message","seq":101,"time":1785122256269,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"497356eb-0561-4849-8d2a-02bebadcd432"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} {"type":"tool/call","seq":102,"time":1785122256269,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} {"type":"tool/code-dispatch-start","seq":103,"time":1785122256332,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} -{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{tmpdir}}/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} {"type":"user/message","seq":106,"time":1785122256338,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"90d60955-ebee-408a-8d12-41a305b3bf99"},"surfaceOp":"append"} {"type":"step/end","seq":107,"time":1785122256338,"data":{"turn":1,"step":1}} {"type":"step/start","seq":108,"time":1785122256347,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 1d41f4aa7e..38bff405f5 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"/tmp/cordis-inspect-jsdoc","delegationDepth":0} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{tmpdir}}/cordis-inspect-jsdoc","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"48efc8f5-a397-491b-b7a1-179a1185ac2f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784449176718,"data":{"title":"Inspect the exact tools service","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index 23b9fc2443..8c9343ab6e 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"c9828d19-2c86-4a4f-9868-c9c28f345358"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index afb6bead2b..0f3efd33ad 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"3d8fced9-efab-4698-b76a-e452746fadc6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt triggers a recorded","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 5233644d6e..1d90e6abd8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2","delegationDepth":0} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"{{tmpdir}}/acp-snap-cwd-cbBLh2","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821261714,"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"},"role":"user","id":"8fcf378f-b720-4a86-be32-95ddec1651c3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821261714,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 85eda59e85..25f7f7d635 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od","delegationDepth":0} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"{{tmpdir}}/acp-snap-cwd-Hn29Od","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821263241,"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"},"role":"user","id":"1f206016-2423-4b51-80bb-df15468298c5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821263241,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index ae51a7db46..e2899ba345 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"/tmp/acp-snap-cwd-0BxHdV","delegationDepth":0} +{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"{{tmpdir}}/acp-snap-cwd-0BxHdV","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352084740,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352084740,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"6b1ee31e-9c1a-41f3-9647-153d6d98e1a5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352084740,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{tmpdir}}/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}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"c9b571e1-3a63-4a97-af3e-41ac1bdc8e24"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"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":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{tmpdir}}/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 52084c3db7..a0eb0d5f6f 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd","delegationDepth":0} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"{{tmpdir}}/acp-snap-cwd-vmEGzd","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821264846,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"e4d528b4-0dd8-4aa9-853e-3d00f25b31aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821264846,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,7 +16,7 @@ {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 65ebc70d21..cc43daf7bd 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB","delegationDepth":0} +{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"{{tmpdir}}/acp-snap-cwd-QzoqnB","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611702550,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611702550,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"c367f2cd-f9b5-44a4-a363-fdb97d469ad2"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783611702550,"data":{"title":"Do NOT use the read","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db6924d3-7ca0-4a50-9bec-9f976b1f493d"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":78,"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":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} +{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -40,7 +40,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0e331a4c-4e8d-4daf-a909-80d78a03bdf7"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"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":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 de2066dc05..052ccce5f9 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"/tmp/acp-snap-cwd-N9HCkt","delegationDepth":0} +{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"{{tmpdir}}/acp-snap-cwd-N9HCkt","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352099838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352099839,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"80cf70ac-0b37-401a-96d2-c54056300cd4"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352099839,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","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}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":92,"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":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} +{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{tmpdir}}/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}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 5f19c1b74b..62cfbff6c7 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"/tmp/acp-snap-cwd-PEETkS","delegationDepth":0} +{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"{{tmpdir}}/acp-snap-cwd-PEETkS","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352072468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352072469,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"7396fa9a-4068-42a6-b153-2b5ade098d32"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352072469,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 6da33759a6..96da3ed2c1 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"/tmp/acp-snap-cwd-hH2sGY","delegationDepth":0} +{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"{{tmpdir}}/acp-snap-cwd-hH2sGY","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352092220,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352092221,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"5e890158-f455-445a-b265-e0cd1b18af36"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352092221,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"17d77822-5c76-4c09-acb6-8ff891129da8"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 1dcc0af155..90440eaf45 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"/tmp/acp-snap-cwd-sNvn5N","delegationDepth":0} +{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"{{tmpdir}}/acp-snap-cwd-sNvn5N","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352078754,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352078754,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"f11a6473-4b11-4205-a73a-edd879e1ec56"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352078754,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6a58e768-f922-4850-832e-bfb43d4ab4fc"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":63,"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":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index b7fd854b5a..5536ca446e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY","delegationDepth":0} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"{{tmpdir}}/acp-snap-cwd-WxJGUY","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"775ddb99-fdd1-404f-ba14-4cc37b6ac2c8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783962504117,"data":{"title":"Call the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index 5cb9143a71..3be55c79c5 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"/tmp/acp-snap-cwd-LEetSL","delegationDepth":0} +{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"{{tmpdir}}/acp-snap-cwd-LEetSL","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352196662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352196662,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"b3957310-0893-4e41-88b2-715c102b5a9a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352196662,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 33df927a74..0f01aa991d 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 @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS","delegationDepth":0} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"{{tmpdir}}/acp-snap-cwd-iKVciS","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"40085b3d-6b87-4b86-859e-b34786c9a12f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index b5d38ede9e..58199bf49e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{tmpdir}}/acp-snap-cwd-wDnkVo","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"57df50c1-78e1-4b8a-857a-c2ae2192dadd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 62f310d322..d464ba80a1 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"/tmp/acp-snap-cwd-QUDqlk","delegationDepth":0} +{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"{{tmpdir}}/acp-snap-cwd-QUDqlk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"7911469c-1e33-4741-9d32-49ecc6a01f0b"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"7b887c49-97bd-46f9-aea4-c462d385a8ee"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 92c0dc44da..26a57d5b5d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-r6rWZp","delegationDepth":0} +{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"{{tmpdir}}/acp-snap-cwd-r6rWZp","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c63da2f2-916d-42cc-8e6f-c9520e1641cd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522140647,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 2ede4564c3..aa5caf16f8 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP","delegationDepth":0} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"{{tmpdir}}/acp-snap-cwd-x67BsP","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"5a3821d5-de5b-4b9c-85b7-d53dca51af5c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783986962235,"data":{"title":"Call the bash tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 1cec7b19c9..f577fa4b0b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"/tmp/acp-snap-cwd-VGFtPi","delegationDepth":0} +{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"{{tmpdir}}/acp-snap-cwd-VGFtPi","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352228441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352228442,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"7d8954d3-d4e7-4ca6-ba3d-0c5de95a3ace"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352228442,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 3ebaa26b9a..7d0ae9c642 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"/tmp/acp-snap-cwd-7Hbu0m","delegationDepth":0} +{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"{{tmpdir}}/acp-snap-cwd-7Hbu0m","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352214604,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352214605,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8aab0b74-e7e0-4c3c-90a3-19a81f2b9c6a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352214605,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 45d09acac1..52038a11ce 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"/tmp/acp-snap-cwd-aopaZV","delegationDepth":0} +{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"{{tmpdir}}/acp-snap-cwd-aopaZV","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ea34d65f-e154-4b2a-bea8-3345fdd96658"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"174d8732-a32f-4eb0-8471-d8b3291a34f2"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index 1a9903ce2a..d862c273a0 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-ESgqLu","delegationDepth":0} +{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"{{tmpdir}}/acp-snap-cwd-ESgqLu","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c76f1de4-cf89-4f0f-a861-bc699f579f78"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522152397,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index 24c678f292..f8f0d912e3 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"4133e3ae-3f16-4e96-b6dc-5b194fcd9a50"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the lsp tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 2425d401ad..9e6a2ad199 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{tmpdir}}/acp-snap-cwd-aN2GRR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"77c88536-5dcd-423c-b2f1-c432d5f057fd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352113765,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl index 602b78691d..2bf42fa2a6 100644 --- a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl +++ b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{tmpdir}}/acp-snap-cwd-wDnkVo","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"a597583b-7e90-4d4d-9b6a-bb1ab7617417"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl index 9df7e1485d..ce5d64499f 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"b4f8388c-8494-409b-8230-c98e14e0899b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,8 +15,8 @@ {"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"380ff5b4-d7f1-4c36-b87d-9a42ce1b264c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} {"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} -{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl index a0ebe7a13d..cedd8cf0a2 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f8d5e91c-eb5a-4223-8295-acf7ff357ccc"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index 8c3644959c..2248a9148a 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"c7f37e71-3cad-428e-b267-311499b38e9d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Write the todo list 'watch","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index c4dea917f0..ee8f640a05 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"4cca69f9-35bf-4a89-ad5e-c36296496f75"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 4 with","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl index d3bd535ce1..bca51d0679 100644 --- a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"/tmp/session-title-after-turn","delegationDepth":0} +{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"{{tmpdir}}/session-title-after-turn","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"00000000-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index bab6f80e1c..f3ef5e874e 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW","delegationDepth":0} +{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{tmpdir}}/acp-snap-cwd-DhYwNW","delegationDepth":0} {"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"},"role":"user","id":"9c670f1c-3508-4b98-9cae-21f363652d6e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1784903324935,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cc7d430d-d011-4428-8572-0274c6082277"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[6,7,8,9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784903324936,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.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"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{tmpdir}}/acp-snap-cwd-DhYwNW/.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"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784903324944,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1784903324952,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index c682fec096..68e9154acc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"/tmp/subagent-depth-two","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{tmpdir}}/subagent-depth-two","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1784540790312,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790312,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"e1664eb5-480b-4987-a0a3-4fcd85ccb04d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790312,"data":{"title":"Call subagent once. Ask that","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 16394c6e65..1aadcf6302 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"/tmp/subagent-depth-two","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{tmpdir}}/subagent-depth-two","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} {"type":"turn/start","seq":0,"time":1784540790319,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790319,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"9299d7d1-85e0-4e05-93e4-34d2cf6bafc8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790319,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index 9561b6cc4c..d32f31d9a7 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"/tmp/subagent-depth-two","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{tmpdir}}/subagent-depth-two","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784540790290,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790291,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"f74eb6a3-3869-4b1c-ba3c-5b6db530ac67"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790291,"data":{"title":"Delegate through two child generations.","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 98299cec2a..309eff763f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"/tmp/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} +{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{tmpdir}}/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index debea23a22..6458ab9c98 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"/tmp/acp-snap-cwd-0HLtcD","delegationDepth":0} +{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"{{tmpdir}}/acp-snap-cwd-0HLtcD","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 5b1c5fcf61..5ba2047d2c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} +{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"214ad816-8421-48ff-b501-ca51716d761f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352145224,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index ce3fd5980d..5bed074a44 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} +{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index 65320dbaaf..51b1539129 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"/tmp/acp-snap-cwd-i43JSF","delegationDepth":0} +{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index ef7d8fcbd5..e2dabc8463 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"4088c6ea-4806-4d0a-a5a7-b430ba9fcb7e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352127670,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index f1c2813380..b181e92ec9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"804b9ed3-e2ed-495e-9840-8e0f657661fe"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352129662,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 3179edb205..f0805a895c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"/tmp/acp-snap-cwd-28z5Of","delegationDepth":0} +{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"26ff1621-20b5-4c1e-b546-ed4c6f6ec99e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352126251,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 6b107d1c1c..680c7505ea 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"/tmp/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} +{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{tmpdir}}/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f3a2e52a-cfc3-4f9a-b25a-cb48f61e598e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352120856,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index 51f0899888..3374bc0172 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"/tmp/acp-snap-cwd-rbeWyt","delegationDepth":0} +{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"{{tmpdir}}/acp-snap-cwd-rbeWyt","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"36c0b82b-ab96-4985-9b44-8895eeedd725"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352119274,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 56aa46d2fa..8f4e11008d 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{tmpdir}}/acp-snap-cwd-ka5r8w","delegationDepth":0} {"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"},"role":"user","id":"2da6fcd7-2410-460a-bb8f-bc6491f7b0b0"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index df70a431bd..fc5a806b56 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{tmpdir}}/acp-snap-cwd-AYilT7","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"18c389cb-ab26-4a60-96aa-a1314eab3759"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352057655,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 9107893db5..4f30f2987e 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"/tmp/acp-snap-cwd-OwUkBh","delegationDepth":0} +{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"{{tmpdir}}/acp-snap-cwd-OwUkBh","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352044771,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352044771,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"033e6f20-6021-4ecc-a80f-de758a3dc877"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352044771,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index f92f59bc11..7a3812162f 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"/tmp/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"{{tmpdir}}/acp-snap-cwd-hqkZWE","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"6c8e9279-bb26-4369-b425-951cd33d6b15"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 4c00ccc7c1..30b43a8e38 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"660a2954-67fc-4406-8703-189f3c0ee81e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600636316,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 700410718c..3d3580ef08 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"7752d242-0fc3-421c-ad28-60333479140c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600631838,"data":{"title":"Use the workflow tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index da3bbd7ad7..a1aa002ccf 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7bee8c9d-684e-42e2-a906-54479a4360c0"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -12,7 +12,7 @@ {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"11b21c20-5425-41ad-8fa0-d8b89cc40f87"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} {"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"73cb82c7-85c5-4d87-bb6c-cad10b7ef6de"},"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index e5a133dea3..6cdf389f35 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"/tmp/acp-snap-cwd-rxbEpP","delegationDepth":0} +{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"{{tmpdir}}/acp-snap-cwd-rxbEpP","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352264080,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352264081,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"77ac6781-b796-4060-b670-63baa39a986b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352264081,"data":{"title":"A file named greeting.txt in","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"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}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 53a8342d93..c09ef9ee4b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -7,6 +7,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, + tokenizeSessionFixtureTmpdir, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -260,7 +261,9 @@ describe('headless stream-json snapshots', () => { if (existing === undefined || file === undefined) { throw new Error(`headless snapshot has no fixture for persisted log ${index}`) } - const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext) + const stable = tokenizeSessionFixtureTmpdir( + stabilizeRefreshLog(actual.content, existing, replacements, actualContext), + ) await writeFile(file, stable) return stable })) @@ -456,7 +459,9 @@ describe('headless stream-json snapshots', () => { content: actual.content, } const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext) + expectedSession = tokenizeSessionFixtureTmpdir( + stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), + ) await writeFile(ptySessionFixture, expectedSession) } const expectedContext = contextFromLogs([expectedSession]) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index c41a628b57..536290c221 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"b07a1eeb-2060-44e5-87d3-05d315a4a74b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 7a12caea56..a311ff1a80 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"1d565b63-5689-4c09-9686-abd3ee379e28"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 2ec15fd69d..59535957c1 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-headless","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"0dda35fe-e148-4400-b837-2f6e6fe40ae6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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":7,"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/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index cda0e3e2f6..c5c61ec9e6 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"8cc78530-3ead-4c68-a38f-dcc14d6a2a82"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl index ca36330c36..d9b1fe3d3c 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"{{tmpdir}}/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl index c722158098..a478992707 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"{{tmpdir}}/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl index c452fb5fa8..adcc6a5092 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"} +{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"{{tmpdir}}/ralph-headless"} diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 26254f0cf1..f09242106c 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -20,6 +20,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, + tokenizeSessionFixtureTmpdir, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -238,7 +239,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { await Promise.all(ordered.map(async (log, index) => { const file = fixtureFiles(scenario)[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(log.content)) + await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureTmpdir(log.content))) })) } @@ -257,12 +258,19 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const existing = expectedContents[index] const file = files[index] if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = stabilizeRefreshLog(log.content, existing, replacements, actualContext) + const stable = scrubRequestHeaders(tokenizeSessionFixtureTmpdir( + stabilizeRefreshLog(log.content, existing, replacements, actualContext), + )) await writeFile(file, stable) return stable })) } + for (const [index, expected] of expectedContents.entries()) { + expect(scrubRequestHeaders(expected), `${scenario.name} session fixture ${index} carries request-header bulk`) + .toBe(expected) + } + // Persisted transcripts match the committed fixtures. const expectedContext = contextOfContents(expectedContents) for (const [index, log] of ordered.entries()) { diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index d3ee0a2f5f..b4f47e329b 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"/tmp/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{tmpdir}}/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"295507c3-4ba7-4695-a535-73e75046abb3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 0e9fb1c561..d4bab038ea 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} +{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{tmpdir}}/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} {"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"fb1dfb09-5b8b-4343-8a04-49cc4c7c082e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index 23127d77b8..c4e588b44f 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{tmpdir}}/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"e2664740-19d2-4e54-81e5-63ff154af28e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index f3706e24d0..7658bbe1c7 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"/tmp/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{tmpdir}}/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"4cb523e7-19c9-45d0-8799-911a78c26207"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl index f32d1160db..5e17b8f7c1 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{tmpdir}}/acp-snap-cwd-mrFUuk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl index 42a7dcd8ea..2c47890942 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"/tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia"} +{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"{{tmpdir}}/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia"} {"type":"turn/start","seq":0,"time":1785052797817,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[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,151,152,153,154,155],"surfaceOp":"append"} {"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}} {"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}} -{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: /tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} +{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{tmpdir}}/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} {"type":"tool/result","seq":160,"time":1785052799925,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","content":[{"type":"text","text":"200"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} {"type":"step/end","seq":161,"time":1785052799926,"data":{"turn":1,"step":1}} {"type":"step/start","seq":162,"time":1785052799928,"data":{"turn":1,"step":2}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl index 858af9a595..2ec7e9f98c 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"/tmp/dsh-tui-snapshot-code-mode-7FNsKO"} +{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"{{tmpdir}}/dsh-tui-snapshot-code-mode-7FNsKO"} {"type":"turn/start","seq":0,"time":1785014512139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl index 26519d458e..32354715bc 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl index 9daa8958dc..59ae1a98af 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index b9ce1d12da..e95e7cebe1 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type": "session", "version": 0, "id": "11111111-1111-4111-8111-111111111111", "createdAt": 1783950000000, "cwd": "/tmp/advanced-acp", "delegationDepth": 0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-acp","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop 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}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl index e1dd4a461a..fce55b2b16 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl index 71bad8720d..b411e2f12d 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl index 76b7ceba58..da94ddbd89 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{tmpdir}}/acp-snap-cwd-aN2GRR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl index 878948fd26..7d183a245b 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{tmpdir}}/acp-snap-cwd-AYilT7","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 98abee3f0a..545ce29862 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, tokenizeSessionFixtureTmpdir } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -415,10 +415,16 @@ async function runScenario(scenario: Scenario): Promise { async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { const dir = scenarioDir(scenario) await mkdir(dir, { recursive: true }) - await writeFile(join(dir, 'session.jsonl'), scrubRequestHeaders(rawSessionLog(result.parent))) + await writeFile( + join(dir, 'session.jsonl'), + scrubRequestHeaders(tokenizeSessionFixtureTmpdir(rawSessionLog(result.parent))), + ) expect(result.children).toHaveLength(scenario.childSessions ?? 0) for (const [index, child] of result.children.entries()) { - await writeFile(join(dir, `session.${index + 1}.jsonl`), scrubRequestHeaders(rawSessionLog(child))) + await writeFile( + join(dir, `session.${index + 1}.jsonl`), + scrubRequestHeaders(tokenizeSessionFixtureTmpdir(rawSessionLog(child))), + ) } } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index d30a2fb2a7..7c29312084 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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 packages/support/acp-snapshot/README.md -README.md: d0d2ae794caa35744b999ecad96d09e226ecc7ca -README.zh.md: de1e5d370e4c58cc565ec4a12fc20467a89cbed8 +README.md: 0bc27e263c9dbbeb83bd56628e96c9fe903ea722 +README.zh.md: acee0f9da993dde96c6ce6be9aa10b56ebf3044b diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index d0d2ae794c..0bc27e263c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,7 +8,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureTmpdir` (the generated temporary-workspace root → `{{tmpdir}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default temporary workspaces are stored in session fixtures as `{{tmpdir}}/` so macOS and Linux recordings converge; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index de1e5d370e..acee0f9da9 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,7 +8,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureTmpdir`(生成的临时 workspace 根目录 → `{{tmpdir}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认临时 workspace 在会话 fixture 中存储为 `{{tmpdir}}/`,使 macOS 与 Linux 录制结果一致;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 82e5be7eb2..d6e2552d55 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -38,6 +38,7 @@ export { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, + tokenizeSessionFixtureTmpdir, type CwdPathMode, type NormalizeContext, type NormalizeOptions, diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index b63833dc65..387be8c250 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -8,6 +8,7 @@ const SESSION_ID = '{{sessionId}}' const CWD = '{{cwd}}' +const TMPDIR = '{{tmpdir}}' const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' const EVENT_TIME = '{{eventTime}}' @@ -21,6 +22,7 @@ const EMBEDDED_EVENT_TIME_RE = /^( "time": )\d+(?=,\r?$)/gm const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g const EVENT_READ_TARGET_REGION_RE = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n[\s\S]*?(?=\r?\n```(?:\r?\n|$)|\r?\n\r?\n\(Omitted )/ +const PATH_TEXT_BOUNDARY_RE = /[\s<>'"`()\[\]{},;:!?=]/ /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -80,9 +82,60 @@ export interface NormalizeOptions { cwdPathMode?: CwdPathMode } +/** Return every known spelling of the generated cwd, most specific first. */ +function cwdSpellings(ctx: NormalizeContext): string[] { + const spellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])] + .filter(spelling => spelling.length > 0) + const macAliases = spellings + .filter(spelling => spelling.startsWith('/') && !spelling.startsWith('/private/')) + .map(spelling => `/private${spelling}`) + return [...new Set([...spellings, ...macAliases])] + .sort((left, right) => right.length - left.length) +} + +/** Whether an embedded cwd match starts and ends at a path/text boundary. */ +function isCwdMatch(value: string, start: number, length: number): boolean { + const before = value[start - 1] + const after = value[start + length] + const afterPunctuation = value[start + length + 1] + const startsAtBoundary = before === undefined || PATH_TEXT_BOUNDARY_RE.test(before) + const endsAtBoundary = after === undefined + || after === '/' + || after === '\\' + || PATH_TEXT_BOUNDARY_RE.test(after) + || after === '.' && (afterPunctuation === undefined || /\s/.test(afterPunctuation)) + return startsAtBoundary && endsAtBoundary +} + +/** Replace one cwd spelling without matching a longer path segment that merely shares its prefix. */ +function replaceCwdSpelling(value: string, spelling: string, replacement: string): string { + let cursor = 0 + let out = '' + while (cursor < value.length) { + const match = value.indexOf(spelling, cursor) + if (match < 0) return out + value.slice(cursor) + const end = match + spelling.length + if (isCwdMatch(value, match, spelling.length)) { + out += value.slice(cursor, match) + replacement + cursor = end + } else { + out += value.slice(cursor, end) + cursor = end + } + } + return out +} + +/** Replace every known cwd spelling with one stable token. */ +function replaceCwd(value: string, ctx: NormalizeContext, replacement: string): string { + let out = value + for (const spelling of cwdSpellings(ctx)) out = replaceCwdSpelling(out, spelling, replacement) + return out +} + /** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string { - let out = value + let out = replaceCwd(value, ctx, CWD) // Filesystem APIs can report one directory with several spellings. Replace // every known spelling longest-first so a shorter alias cannot corrupt a // longer one before it is tokenized. macOS additionally symlinks @@ -90,11 +143,6 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM // omit the /private prefix while fs tools resolve symlinks, so cover the // prefixed form of every spelling too, then collapse a residual prefixed // token. - const cwdSpellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])] - .filter(spelling => spelling.length > 0) - .flatMap(spelling => [`/private${spelling}`, spelling]) - .sort((left, right) => right.length - left.length) - for (const spelling of cwdSpellings) out = out.split(spelling).join(CWD) out = out.split(`/private${CWD}`).join(CWD) if (cwdPathMode === 'canonical') { // Restrict separator conversion to paths rooted at the cwd token. A global @@ -134,6 +182,68 @@ function scrubValue(value: unknown, ctx: NormalizeContext, cwdPathMode: CwdPathM return value } +/** Escape one literal path segment for use in a regular expression. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** Replace any absolute spelling whose final segment is the generated cwd basename. */ +function tokenizeFixtureString(value: string, ctx: NormalizeContext, portableCwd: string, basename: string): string { + const exact = replaceCwd(value, ctx, portableCwd) + const absoluteCwd = new RegExp( + String.raw`(?:[A-Za-z]:)?[\\/](?:[^\\/\s<>"]+[\\/])*${escapeRegExp(basename)}` + + String.raw`(?=$|[\\/\s<>'"()\[\]{},;:!?=])`, + 'g', + ) + return exact.replace(absoluteCwd, (match, offset: number) => + exact.slice(0, offset).endsWith(TMPDIR) ? match : portableCwd) +} + +/** Recursively replace generated-cwd spellings while preserving every other JSON value. */ +function tokenizeFixtureValue( + value: unknown, + ctx: NormalizeContext, + portableCwd: string, + basename: string, +): unknown { + if (typeof value === 'string') return tokenizeFixtureString(value, ctx, portableCwd, basename) + if (Array.isArray(value)) return value.map(item => tokenizeFixtureValue(item, ctx, portableCwd, basename)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + tokenizeFixtureValue(item, ctx, portableCwd, basename), + ])) + } + return value +} + +/** + * Store one generated temporary workspace with a platform-neutral root while + * retaining its run-specific basename and every other session value. The + * caller opts in only for workspaces created under a platform temporary root; + * explicitly relocated workspaces keep their real path. + * + * @param rawLog The raw or refresh-stabilized session JSONL fixture. + * @returns Compact JSONL whose known cwd spellings start with `{{tmpdir}}`. + * @throws If a non-empty line is invalid JSON or the session cwd has no basename. + */ +export function tokenizeSessionFixtureTmpdir(rawLog: string): string { + const lines = rawLog.split('\n') + const firstLine = lines.find(line => line.trim().length > 0) + const header = firstLine === undefined ? undefined : JSON.parse(firstLine) as { cwd?: unknown } + const cwd = typeof header?.cwd === 'string' ? header.cwd : '' + const basename = cwd.split(/[\\/]/).at(-1) + if (basename === undefined || basename.length === 0) { + throw new Error('acp-snapshot: cannot tokenize a cwd without a basename') + } + const portableCwd = `${TMPDIR}/${basename}` + const ctx: NormalizeContext = { sessionIds: [], cwd } + return lines.map((line) => { + if (line.trim().length === 0) return line + return JSON.stringify(tokenizeFixtureValue(JSON.parse(line), ctx, portableCwd, basename)) + }).join('\n') +} + /** * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable expected output * in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 289d615123..9ba8eda8af 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -31,6 +31,7 @@ import { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, + tokenizeSessionFixtureTmpdir, } from './normalize.ts' /** The readable system-prompt snapshot beside its owning header pin. */ @@ -1007,6 +1008,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const scrub = scenario.pinsHeader === true ? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log)) : scrubRequestHeaders + const portableFixture = scenario.workspaceParent === undefined + ? tokenizeSessionFixtureTmpdir + : (log: string): string => log const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) : [] @@ -1024,14 +1028,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), ] const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( + await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture( REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary, - )) + ))) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( + await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(portableFixture( REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child, - )) + ))) } if (RECORDING) { const outputNames = new Set(outputFixtureFiles) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index c7b118a3ba..cc4f4c7dbf 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -7,6 +7,7 @@ import { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, + tokenizeSessionFixtureTmpdir, } from '../src/normalize.ts' /** @@ -370,6 +371,54 @@ describe('normalizeSessionLog', () => { }) }) +describe('tokenizeSessionFixtureTmpdir', () => { + it.each([ + { + name: 'macOS', + context: { + sessionIds: [], + cwd: '/var/folders/2g/snapshot/T/acp-snap-cwd-abc123', + cwdAliases: ['/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123'], + }, + reportedCwd: '/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123', + }, + { + name: 'Linux', + context: { + sessionIds: [], + cwd: '/tmp/acp-snap-cwd-abc123', + }, + reportedCwd: '/tmp/acp-snap-cwd-abc123', + }, + ])('stores $name temporary workspaces with one portable root token', ({ context, reportedCwd }) => { + const raw = [ + JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: context.cwd }), + JSON.stringify({ + type: 'tool/result', + seq: 1, + time: 2, + data: { + content: [{ + type: 'text', + text: `wrote ${reportedCwd}/proof.txt. cwd ${context.cwd}. Next; kept ${context.cwd}-backup, ${context.cwd}.backup, and /tmp/authored.txt`, + }], + }, + }), + '', + ].join('\n') + + const out = tokenizeSessionFixtureTmpdir(raw) + + expect(out).toContain('"cwd":"{{tmpdir}}/acp-snap-cwd-abc123"') + expect(out).toContain('wrote {{tmpdir}}/acp-snap-cwd-abc123/proof.txt') + expect(out).toContain('cwd {{tmpdir}}/acp-snap-cwd-abc123. Next') + expect(out).toContain(`${context.cwd}-backup`) + expect(out).toContain(`${context.cwd}.backup`) + expect(out).toContain('/tmp/authored.txt') + expect(out).not.toContain(`${reportedCwd}/proof.txt`) + }) +}) + describe('extractSnapshotSpillPaths', () => { it('maps each spill filename to its full matched path, last match wins per name', () => { const log = [ diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 9ce6d17cf9..4660195eef 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -177,12 +177,17 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8') expect(schemas).toContain('"description": "D1"') expect(schemas).not.toContain('"name":"stale"') + + const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8') + expect(pinSession).toContain('"cwd":"{{tmpdir}}/pin-cwd"') }) }) describe('defineAcpSnapshotSuite: record inventory write-back', () => { it('creates a missing primary fixture and prunes stale child fixtures', () => { - expect(readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8')).toContain('"type":"session"') + const fixture = readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8') + expect(fixture).toContain('"type":"session"') + expect(fixture).toContain('"cwd":"{{tmpdir}}/acp-snap-cwd-') expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() }) }) From db0b43bf86b492999454b6163bb80f916c280d5e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:40:51 +0800 Subject: [PATCH 26/61] persistence: advance schemas after removing policy columns The simplification deletes sandbox_mode and approval_policy from the parent branch's SQLite layouts. Restoring master's older version numbers would violate the monotonic schema contract and could make a database created by the parent look current under a different layout. Advance durable session persistence from schema 11 to 12 and the disposable session-query index from 6 to 7. The former rejects the incompatible parent layout; the latter resets its derived tables through the existing version-mismatch path. JSONL shares SESSION_FORMAT_VERSION 0 during pre-release, so explicitly reject the retired sandboxMode and approvalPolicy header fields instead of silently dropping the only inherited policy facts from a parent-produced child log. New logs carry those facts as ordinary seeded events. Focused JSONL, SQLite persistence, and SQLite query suites cover all affected source lines and branches. --- .../session-persistence-jsonl/src/format.ts | 3 +++ .../session-persistence-jsonl/tests/jsonl.spec.ts | 6 ++++++ .../session-persistence-sqlite/src/schema.ts | 2 +- .../session-persistence-sqlite/tests/sqlite.spec.ts | 2 +- packages/session-query/session-query-sqlite/src/schema.ts | 2 +- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 03e8b15da0..f7d338877d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -64,6 +64,9 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { * @returns the header, absent optional fields omitted. */ export function fromHeaderLine(line: HeaderLine): SessionHeader { + if (Object.hasOwn(line, 'sandboxMode') || Object.hasOwn(line, 'approvalPolicy')) { + throw new Error('session header uses retired policy baseline fields') + } return { version: line.version, id: line.id, diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index f57b105a8b..5171bb8585 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1023,6 +1023,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toContain('big') }) + it.each(['sandboxMode', 'approvalPolicy'] as const)('rejects the retired %s header field', (field) => { + const line = { ...toHeaderLine(meta('retired-policy-header')), [field]: 'read-only' } + expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`))) + .toThrow(/retired policy baseline fields/) + }) + it('list rejects a header whose cwd does not identify its physical log', async () => { const m = meta('misplaced', '/stored') await ctx.sessionPersistence.create(m) diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..bdbd657e3e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 10 +export const SCHEMA_VERSION = 12 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 82f00519ba..ddc46880cf 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -609,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(10) + expect(SCHEMA_VERSION).toBe(12) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..f5c2d81bcf 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 From 4cf7603be427dd79910e68a14506385ff5fcbed0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:45:01 +0800 Subject: [PATCH 27/61] test(snapshot): enforce portable fixture invariants --- .../tests/snapshots/bash-tool/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 2 +- .../snapshots/subagent-spawn/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../acp-snapshot/tests/normalize.spec.ts | 34 +++++++++++++++---- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index b4f47e329b..c633ef6a37 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"295507c3-4ba7-4695-a535-73e75046abb3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","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."}},"required":["command","description"]}},{"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":"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":"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","additionalProperties":false,"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":"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":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097396437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097396438,"data":{"turn":1,"step":1,"index":0,"dt":[219,22,1,0,0,0,1,24,25,0,0,25,1,24,1,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} {"type":"assistant/chunk","seq":23,"time":1785097396856,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index d4bab038ea..2e121dfaba 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"fb1dfb09-5b8b-4343-8a04-49cc4c7c082e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097410284,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","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."}},"required":["command","description"]}},{"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":"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":"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","additionalProperties":false,"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":"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":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097410836,"data":{"turn":1,"step":1,"index":0,"dt":[149,26,0,0,24,1,0,0,0,25,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} {"type":"assistant/chunk","seq":20,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index c4e588b44f..7db65da9ba 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"e2664740-19d2-4e54-81e5-63ff154af28e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","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."}},"required":["command","description"]}},{"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":"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":"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","additionalProperties":false,"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":"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":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097409495,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097409496,"data":{"turn":1,"step":1,"index":0,"dt":[170,25,1,0,0,0,0,24,0,1,0,0,0,26,0,0,0,0,0,26,0,0,0,0,0,30,0,0,1,0,0,20,1,0,0,28,0,1,0,0,0,23,1,0,0,0,0,25,1,25,26,1,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," with"," description"," '","echo"," probe","'"," and"," prompt"," '","Reply"," with"," exactly",":"," child"," answer"," ","42",".'\n","2","."," Then"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim",".\n\n","Let"," me"," do"," this"," step"," by"," step","."]}} {"type":"assistant/chunk","seq":61,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index 7658bbe1c7..4827a0548f 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"4cb523e7-19c9-45d0-8799-911a78c26207"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","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."}},"required":["command","description"]}},{"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":"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":"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","additionalProperties":false,"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":"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":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097381978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097381979,"data":{"turn":1,"step":1,"index":0,"dt":[138,28,27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} {"type":"assistant/chunk","seq":25,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index cc4f4c7dbf..e882ac2c63 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -390,6 +390,14 @@ describe('tokenizeSessionFixtureTmpdir', () => { }, reportedCwd: '/tmp/acp-snap-cwd-abc123', }, + { + name: 'Windows', + context: { + sessionIds: [], + cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`, + }, + reportedCwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`, + }, ])('stores $name temporary workspaces with one portable root token', ({ context, reportedCwd }) => { const raw = [ JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: context.cwd }), @@ -400,7 +408,7 @@ describe('tokenizeSessionFixtureTmpdir', () => { data: { content: [{ type: 'text', - text: `wrote ${reportedCwd}/proof.txt. cwd ${context.cwd}. Next; kept ${context.cwd}-backup, ${context.cwd}.backup, and /tmp/authored.txt`, + text: `wrote ${reportedCwd}/proof.txt. alias /different/root/acp-snap-cwd-abc123/alias.txt. cwd ${context.cwd}. Next; kept ${context.cwd}-backup, ${context.cwd}.backup, and /tmp/authored.txt`, }], }, }), @@ -408,14 +416,26 @@ describe('tokenizeSessionFixtureTmpdir', () => { ].join('\n') const out = tokenizeSessionFixtureTmpdir(raw) + const result = JSON.parse(out.split('\n')[1] as string) as { + data: { content: { text: string }[] } + } + const resultText = (result.data.content[0] as { text: string }).text expect(out).toContain('"cwd":"{{tmpdir}}/acp-snap-cwd-abc123"') - expect(out).toContain('wrote {{tmpdir}}/acp-snap-cwd-abc123/proof.txt') - expect(out).toContain('cwd {{tmpdir}}/acp-snap-cwd-abc123. Next') - expect(out).toContain(`${context.cwd}-backup`) - expect(out).toContain(`${context.cwd}.backup`) - expect(out).toContain('/tmp/authored.txt') - expect(out).not.toContain(`${reportedCwd}/proof.txt`) + expect(resultText).toContain('wrote {{tmpdir}}/acp-snap-cwd-abc123/proof.txt') + expect(resultText).toContain('alias {{tmpdir}}/acp-snap-cwd-abc123/alias.txt') + expect(resultText).toContain('cwd {{tmpdir}}/acp-snap-cwd-abc123. Next') + expect(resultText).toContain(`${context.cwd}-backup`) + expect(resultText).toContain(`${context.cwd}.backup`) + expect(resultText).toContain('/tmp/authored.txt') + expect(resultText).not.toContain(`${reportedCwd}/proof.txt`) + expect(tokenizeSessionFixtureTmpdir(out)).toBe(out) + }) + + it('rejects a log without a session cwd', () => { + expect(() => tokenizeSessionFixtureTmpdir('')).toThrow( + 'acp-snapshot: cannot tokenize a cwd without a basename', + ) }) }) From 6d389d261ef0370d67cc709fde19eac886b4751e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:51:13 +0800 Subject: [PATCH 28/61] docs(policy): describe constructor-seeded inheritance The implementation no longer stores inherited policy in SessionHeader or resolves a second baseline chain, but the feature, sandbox, and approval notes still described that machinery. Keeping those claims would make the smaller design look incomplete and invite reintroduction of the generic persistence surface. Rewrite the owning feature note around the actual delegation snapshot: source-tagged policy events follow the optional fork prefix, ordinary last-event-wins folds establish precedence, and persistence captures the constructor seed with the first materialized batch. Condense the alternatives and consequences to the decisions and coverage that remain load-bearing. Align the sandbox and approval notes plus the subagent-inprocess consumer README with that contract. Update the four Chinese counterparts minimally and re-record each pairing hash so both languages describe the same shipped mechanism. --- .../2026-07-06-approval-seam.i18n.yaml | 6 ++-- .../feature/2026-07-06-approval-seam.md | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +-- .../implemented/feature/2026-07-06-sandbox.md | 6 ++-- .../feature/2026-07-06-sandbox.zh.md | 6 ++-- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +-- .../2026-07-25-subagent-policy-inheritance.md | 29 +++++++++---------- ...26-07-25-subagent-policy-inheritance.zh.md | 29 +++++++++---------- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 ++ .../subagent/subagent-inprocess/README.zh.md | 2 ++ 12 files changed, 45 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index ab3de720a7..51ddb7e70f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-06-approval-seam.md: 729a9cdc5b723c5ddcb13ef6452d7421823fb19f -2026-07-06-approval-seam.zh.md: c51739b96f3e2d5091767bdc07795866df449fa1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md +2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31 +2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 729a9cdc5b..efb4159d73 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. The session POLICY does cross the boundary: a `'never'` parent's children inherit `'never'` via a stamped override ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)), so they are told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index c51739b96f..9a4656f30a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。但会话策略确实会跨过这条边界:`'never'` 父级的子 agent 通过盖章写入的覆盖项继承 `'never'`([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md)),因此它们一开始就被告知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 541c1edf95..9311c6cac7 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: a93e18dac629d55925f9b8d4c621b8d35d386c2e -2026-07-06-sandbox.zh.md: 2ca89a3ea9bad4acd5a9206476c8fba58971ce19 +2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e +2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a93e18dac6..42b78ad834 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -89,12 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — #### Per-session modes: the session log as the store ``` -effective(session) = findLast(the session's OWN post-seed knob events)?.value - ?? the inherited SessionHeader baseline - ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log; the middle rung is the delegation baseline a subagent child's header carries. Restart immunity (resuming a session replays its log and restores its header, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere. Isolation does not make delegation an escape hatch: the in-process subagent driver captures a delegating parent's effective override synchronously at delegation and carries it into each child's creation-time `SessionHeader` (`sandboxMode`/`approvalPolicy`), so a tightened parent binds spawn children, fork children, and grandchildren with no first-turn timing window ([the subagent policy-inheritance Agent Note](2026-07-25-subagent-policy-inheritance.md)). +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 2ca89a3ea9..dfa3349e4d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -89,12 +89,10 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### 按会话模式:会话日志即存储 ``` -effective(session) = findLast(the session's OWN post-seed knob events)?.value - ?? the inherited SessionHeader baseline - ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件;中间层是 subagent 子 agent 的会话头所携带的委派基线。重启免疫(恢复会话时回放其日志并还原其会话头,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。隔离并不使委派成为逃生通道:进程内 subagent 驱动器在委派时同步捕获发起委派的父级的有效覆盖,并将其带入每个子 agent 创建时的 `SessionHeader`(`sandboxMode`/`approvalPolicy`),因此收紧后的父级会约束 spawn 子 agent、fork 子 agent 与孙代 agent,且不存在任何第一轮次的时序窗口([subagent 策略继承 Agent Note](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 8e7928f278..5a96140e0e 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: fcc91310399dd0bbaae3acc444fbf790ae4d0b3c -2026-07-25-subagent-policy-inheritance.zh.md: d06948dc34213e87300bf46ee5f1fc43d8f48fc7 +2026-07-25-subagent-policy-inheritance.md: ae87e7c688a53babca7bb0afc043dc8360871240 +2026-07-25-subagent-policy-inheritance.zh.md: 4e6a11e9cef5fc773b50aa32c477c97e2686c8d0 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index fcc9131039..ae87e7c688 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -6,33 +6,30 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) ## Problem -Session policy overrides are per-session log folds: the effective sandbox mode is `fold(session's sandbox/mode events) ?? deployment default` ([the sandbox Agent Note](2026-07-06-sandbox.md)), and the approval policy folds `approval/policy` the same way. In-process subagent children get a NEW session, so no override crossed the delegation boundary: a spawn child of a `read-only`-switched parent ran under the (possibly wider) deployment default — delegation was a bypass channel for a user's tightening — and a fork child inherited only whatever switch happened to sit inside its completed-turn seed, missing exactly the most common timing (the user switches while the agent is idle, so the switch lands after the last `turn/end` and outside the seed). A `'never'` (headless/CI) approval parent likewise minted children that fell back to a prompting default. The escalation hint a denied child sees ("the approval prompt asks the user") also promised a prompt no answerer would ever deliver. +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. ## Decision -The shared in-process driver (`startInProcessRun` in `packages/subagent/subagent-inprocess`) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the `delegationDepth` precedent: +The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. -- **Capture synchronously at delegation, persist in the creation-time header.** The driver reads `overrideOf(parent.session)` for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and stamps the captured values into the child's creation `meta` (`sandboxMode`/`approvalPolicy` on `SessionHeader`). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable. -- **Only the override chain is copied, and the owners validate on read.** `overrideOf(session)` — the pure `sandboxOverrideOf`/`approvalOverrideOf` exports, surfaced as service methods — resolves `fold(events past the seed boundary) ?? header baseline`, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (`resolve()`, pty-local) and the permission presets (`current`/`set`), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (`ctx.get`, type-only imports, `peerDependenciesMeta.optional`): compositions without them delegate policy-free, unchanged. -- **Fork stale-seed precedence falls out of the seed boundary — scoped to delegation children.** A fork seed may carry the parent's OLD switch events; when a header baseline exists, `overrideOf` folds only events past `header.seedLength` — the baseline was captured from the parent's FULL log at delegation, so seed-carried history is subsumed by it while a switch the child makes ITSELF still outranks it. Without a baseline (a top-level session, or a generic `SessionStore.fork` child that captured no policy meta) the fold covers the whole log: there, seeded switches ARE the replayed inherited truth, and slicing them away would silently widen the child to the deployment default. The log stays free of synthetic events — the header is the baseline's one home, and the canonical `setSandboxMode`/`setApprovalPolicy` write paths remain reserved for real runtime switches. -- **Nesting composes by construction.** A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot `allowed-once` escalation grants never enter a log or header, so they can never leak down the chain. +Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event in the child's constructor seed. The driver places these events after any fork prefix while keeping `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's already-seeded log, so the rule composes without another inheritance mechanism. + +Constructor seeds are validated before publication and captured by persistence when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch. ### What a blocked child experiences -A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (`no approval channel is available`). The recovery path is reporting the denial upward: the parent — owned by a controller that can answer — escalates in its own session or re-delegates after the user widens the mode. An inherited `'never'` skips even that wasted retry: the child's first system prompt already says not to request escalation. +A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. ## Alternatives considered -- **Stamping the inherited override as `sandbox/mode`/`approval/policy` events inside the child's first turn (the shipped first iteration)** — superseded: it kept the log-as-store idiom with zero format changes, but review surfaced a durability hole the turn-enclosure contract cannot patch — an idle SessionStart-style injection persists a complete one-shot turn BEFORE any prompt turn opens, so a crash in that window leaves a resumable-looking child with no inherited policy, and no event anchor exists earlier (creation-time appends are crash-tail garbage, injection turns dispatch no waterfall, `session/event` listeners cannot re-append). The header baseline closes every timing window at once and deletes the listener/prepend/dedup machinery the event approach needed. -- **Stamping at child creation (outside any turn)** — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright. +- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Constructor-seeded events have the required ordering and reuse the existing durable store. +- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already accepts initial log events. +- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. -- **Forcing `approvalPolicy: 'never'` onto every in-process child** — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons. -- **Routing a child's approval asks to the root session's controller** — deferred, unchanged from [the approval-seam Agent Note](2026-07-06-approval-seam.md): the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning `callId` on the start request. Recorded here so the obstacles are not re-derived. +- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). ## Consequences -- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection). -- The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline. -- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase. -- Assembled-app snapshots pin both strengths. The recorded `subagent-sandbox-inheritance` ACP scenario proves a delegated child confined under a read-only DEPLOYMENT policy (the automation-only protocol has no session-scoped switch). The keyless `subagent-inheritance` headless scenario pins the parent-ONLY override on the semantic-checkpoint precedent: a seeded parent log carrying a real `sandbox/mode: read-only` switch under a workspace-write deployment default is resumed through the Loader-booted cli-demo app via a resume fixture plugin and delegates; the child's real write is denied by the real fence, its persisted header carries the inherited baseline, and disabling the driver's capture makes the scenario fail on the physical disk assertion — the assembled-app red/green anchor for the delegation bypass. -- `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release). +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, default omission, and context disposal. +- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. +- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index d06948dc34..4e6a11e9ce 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -6,33 +6,30 @@ Status: implemented ## 问题 -会话策略覆盖项是按会话的日志折叠:生效沙箱模式等于 `fold(session's sandbox/mode events) ?? deployment default`([沙箱 Agent Note](2026-07-06-sandbox.md)),审批策略以同样的方式折叠 `approval/policy`。进程内 subagent 的子 agent(智能体)拿到的是一个全新会话,因此没有任何覆盖项能跨过委派边界:父 agent 已切换到 `read-only` 时,其 spawn 子 agent 却运行在(可能更宽的)部署默认值之下,委派成了绕开用户收紧的旁路通道;fork 子 agent 只能继承恰好落在其已完成轮次种子内的切换,而恰恰漏掉最常见的时机(用户在 agent 空闲时切换,切换落在最后一个 `turn/end` 之后、种子之外)。审批策略为 `'never'`(无头/CI 场景)的父 agent,其创建出的子 agent 同样回退到了会向用户弹出提示的默认策略。被拒的子 agent 看到的升级提示文案(「审批提示会询问用户」)还承诺了一个永远不会有应答器送达的提示。 +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 ## 决策 -共享的进程内驱动器(`packages/subagent/subagent-inprocess` 中的 `startInProcessRun`)在委派时同步捕获父级的策略覆盖项,并将其作为创建元数据带入子 agent 不可变的会话头——沿用 `delegationDepth` 先例: +共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 -- **委派时同步捕获,持久化在创建时的会话头中。**驱动器在自己的第一个 await 之前就为两个策略旋钮读取 `overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父级切换属于父级的未来,而非子 agent——并把捕获值盖章写入子 agent 的创建 `meta`(`SessionHeader` 上的 `sandboxMode`/`approvalPolicy`)。该基线从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死它(即便一个作出拒绝的 UserPromptSubmit 钩子否决了第一个提示词,也不会产生任何影响),任何崩溃窗口也不可能丢失它——决定性的场景是空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化了一个完整轮次,在那之后第一个轮次内的事件尚不存在,而会话已经看起来可以恢复。 -- **只复制覆盖链,且由策略 owner 在读取时校验。**`overrideOf(session)`——即纯函数导出 `sandboxOverrideOf`/`approvalOverrideOf`,以服务方法的形式暴露——解析为 `fold(events past the seed boundary) ?? header baseline`,从不包含部署/配置默认值:未切换过的父级不写入任何基线,因此其子 agent 跨重启继续跟随实时默认值。这两个会话头字段在会话边界上只是中性字符串;每个策略 owner 在每次读取时都无条件按自己的封闭词汇校验(即便自己做出的切换会遮蔽基线,损坏的会话头也会大声失败),遇到词汇之外的值即抛出异常。每一个旋钮消费方都经由同一条链解析——强制执行侧(`resolve()`、pty-local)与权限 preset(`current`/`set`)皆然——因此当选中更窄的 preset 时,继承了更宽基线的子 agent 得到的是真实的旋钮切换,而非静默的空操作。驱动器以可选方式消费这两个服务(`ctx.get`,仅类型导入,`peerDependenciesMeta.optional`):未挂载它们的组合照旧进行无策略委派,行为不变。 -- **fork 陈旧种子的优先级由种子边界自然得出——仅限委派子 agent。**fork 种子可能携带父级旧的切换事件;当会话头基线存在时,`overrideOf` 只折叠 `header.seedLength` 之后的事件——基线是在委派时从父级的完整日志捕获的,因此种子携带的历史已被它所涵盖,而子 agent 自己做出的切换仍然优先于基线。没有基线时(顶层会话,或未捕获任何策略元数据的通用 `SessionStore.fork` 子会话),折叠覆盖完整日志:此时种子携带的切换本身就是回放所得的继承事实,把它们切掉会把子会话静默放宽到部署默认值。日志中不含任何合成事件——会话头是基线的唯一存放处,规范写入路径 `setSandboxMode`/`setApprovalPolicy` 仍然只留给真实的运行时切换。 -- **嵌套按构造即可组合。**孙代 agent 捕获时解析的是其父级(即上一层的子 agent)的覆盖链(自身折叠 ?? 基线),这条链在每层委派处收拢一级,任意深度均成立。一次性的 `allowed-once` 升级授权从不进入任何日志或会话头,因此永远不可能沿链向下泄漏。 +每个捕获值都会成为子 agent 构造种子中的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。驱动器把这些事件放在任意 fork 前缀之后,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已预置事件的日志,因此无需另一套继承机制即可组合此规则。 + +构造种子在发布前经过校验,并在会话公布时由持久化层捕获。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。 ### 被拦住的子 agent 会经历什么 -受限子 agent 撞上围栏时得到的是普通拒绝标记;升级重试会经过真实的审批 waterfall(瀑布式事件)解析,而其中没有任何应答器认领进程内子 agent,最终落到那个独立的 fail-closed 原因(`no approval channel is available`)。恢复路径是把拒绝向上汇报:父 agent 由一个能够应答的控制方持有,可以在自己的会话里发起升级,或在用户放宽模式后重新委派。继承来的 `'never'` 连这次注定无效的重试都会省去:子 agent 的第一份系统提示词已经写明不要请求升级。 +受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会失败关闭,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 ## 考虑过的替代方案 -- **在子 agent 的第一个轮次内,把继承的覆盖项作为 `sandbox/mode`/`approval/policy` 事件盖章写入(已合入的第一版实现)**:已被取代。它保住了「日志即存储」的惯用法,零格式变更,但评审发现了一个轮次封闭契约无法修补的持久性漏洞:空闲时的 SessionStart 式注入会在任何提示词轮次开启之前就持久化一个完整的一次性轮次,在该窗口内崩溃会留下一个看似可恢复、却没有任何继承策略的子 agent,而且不存在更早的事件锚点(创建时的追加只是崩溃残留的尾部垃圾,注入轮次不派发任何 waterfall,`session/event` 监听器也无法重入追加)。会话头基线一举关闭所有时序窗口,并删除了事件方案所需的监听器/前置安装/去重机制。 -- **在子 agent 创建时(任何轮次之外)盖章**:不予采纳。持久化契约在轮次边界提交,因此轮次开始前的裸事件在重新加载时会被当作撕裂尾部截断;会话不变量测试套件会直接判这种追加失败。 +- **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。构造时预置的事件具备所需顺序,并复用现有持久化存储。 +- **首个提示词监听器**:不予采纳。尽管创建事务已经接受初始日志事件,它仍会引入监听器顺序与更晚的时序边界。 +- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 -- **给每个进程内子 agent 强制设置 `approvalPolicy: 'never'`**:不予采纳。这在今天是事实(没有应答器认领它们),但它会静默排除未来能够服务子 agent 的应答器,并搅浑继承语义;只继承父级的覆盖项既保住 fail-closed 结果,又让每次请求的拒绝原因保持诚实。 -- **把子 agent 的审批请求路由给根会话的控制方**:继续延后,结论与[审批 seam Agent Note](2026-07-06-approval-seam.md) 相比没有变化:ACP 提示必须附着在一个流式工具调用上,后台子 agent 的发起调用早已返回,而且桥接器还需要父链所有权以及 start 请求上携带发起 spawn 的 `callId`。在此记录,以免这些障碍被再次推导。 +- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 ## 后果 -- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。 -- 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。 -- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。 -- 组装后应用的快照钉住两种强度。已录制的 `subagent-sandbox-inheritance` ACP 场景证明了一个被委派的子 agent 被约束在只读的部署级策略之下(这个仅面向自动化的协议没有会话作用域的切换)。无密钥的 `subagent-inheritance` headless 场景则沿语义检查点先例钉住仅父级的覆盖项:在 workspace-write 的部署默认值之下,预置一份携带真实 `sandbox/mode: read-only` 切换的父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它并发起委派;子 agent 的真实写入被真实围栏拒绝,其持久化的会话头携带继承来的基线,而禁用驱动器的捕获会让该场景在物理落盘断言上失败——这就是委派旁路在组装后应用层面的红/绿锚点。 -- `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。 +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、默认值省略与上下文释放。 +- 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 +- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 621b896045..402f3a6684 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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 packages/subagent/subagent-inprocess/README.md -README.md: 3606799e6d16e80473006f82b834a10953270914 -README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15 +README.md: 02d9bd7d2dc792055a13d51402572313855ff1ff +README.zh.md: f55467b37a948ef5a0a34bd45d8a11d7a004ff29 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 3606799e6d..02d9bd7d2d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -18,6 +18,8 @@ The driver follows this sequence: The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. +When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and seeds a source-tagged event after any fork prefix. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). + ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 7f52d3699d..f55467b37a 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -18,6 +18,8 @@ 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在任意 fork 前缀之后预置一条带来源标记的事件。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 + ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 From cc6e5c717390ec52eea92cbb866f5219f06c6ff0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:54:15 +0800 Subject: [PATCH 29/61] docs(session): catalog creation-time seed facts The simplified inheritance path uses Session creation seeds for policy events, and the public CreateSessionOptions JSDoc now names that supported role. The type-equivalent persistence catalog still described seeds as replay/fork-only, so doc-sync correctly rejected the mismatch. Align the explanatory paragraph and exact type-equivalent block in both languages, then re-record the bilingual pair. This keeps the public catalog from understating the constructor seam that makes the simplification possible. --- docs/core-data-structures/persistence.i18n.yaml | 4 ++-- docs/core-data-structures/persistence.md | 4 ++-- docs/core-data-structures/persistence.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ea83035f92..a89ea097e6 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 docs/core-data-structures/persistence.md -persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de -persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 +persistence.md: 94813764bea1b87856d5c7cfc86b568df6408c68 +persistence.zh.md: fe5d497a58502356a0be56eec38edbfdbcf903c1 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 5a660e17d6..94813764be 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (initial events for replay, fork, or creation-time facts) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -86,7 +86,7 @@ Creating a `Session` through the store takes a `seed` (replay/fork an existing e * store folds into a {@link SessionHeader}. */ interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial log events supplied at construction (replay, fork, or creation-time facts). */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index b5477cfc82..fe5d497a58 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(用于回放、fork 或创建时事实的初始事件)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -86,7 +86,7 @@ interface SessionHeader { * store folds into a {@link SessionHeader}. */ interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial log events supplied at construction (replay, fork, or creation-time facts). */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit From b10dad0fcbf36e18bbd8959ab7fb1d85065e00fd Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 22:16:43 +0800 Subject: [PATCH 30/61] fix(persistence): restore pre-identity sessions --- .../2026-06-14-session-persistence.i18n.yaml | 6 +- .../2026-06-14-session-persistence.md | 2 +- .../2026-06-14-session-persistence.zh.md | 2 +- ...ad-pre-identity-session-messages.i18n.yaml | 6 + ...7-28-load-pre-identity-session-messages.md | 38 +++++ ...8-load-pre-identity-session-messages.zh.md | 38 +++++ docs/cordis-catalog/services.md | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/tests/resume.spec.ts | 61 +++++++- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session-persistence/README.md | 6 +- .../session-persistence/README.zh.md | 6 +- .../session-persistence/src/coordinator.ts | 146 +++++++++++++++++- .../session-persistence/src/index.ts | 9 +- .../tests/coordinator-contract.ts | 145 ++++++++++++++++- 18 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index a29fa6e073..5b70e37ebe 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812 -2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md +2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda +2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 75e13b860f..137b2b0112 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising: Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index a6bd400a05..2846ee9234 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml new file mode 100644 index 0000000000..56832b37e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md +2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41 +2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md new file mode 100644 index 0000000000..2901527658 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md @@ -0,0 +1,38 @@ +# Agent Note: Load sessions persisted before message identity + +Status: implemented + +English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) + +## Problem + +The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`. + +Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events. + +## Decision + +`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message::` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant. + +The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths. + +The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction. + +## Alternatives considered + +**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation. + +**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system. + +**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. + +## Consequences + +Pre-identity JSONL and SQLite sessions resume with their original message content, sources, provider provenance, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. + +This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity. + +## Related + +- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md new file mode 100644 index 0000000000..61d57ac9f3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 加载消息标识机制引入前持久化的会话 + +Status: implemented + +[English](2026-07-28-load-pre-identity-session-messages.md) | 中文 + +## 问题 + +带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`。 + +消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 + +## 决策 + +`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 + +同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 + +这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。 + +## 考虑过的替代方案 + +**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。 + +**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 + +**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 + +## 后果 + +消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 + +这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 + +## 相关 + +- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。 +- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac556aaf08..7bb03af778 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1042,7 +1042,9 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply - * frozen; malformed identified messages reject before any stored event is returned. + * frozen. Coordinator-backed implementations upgrade supported pre-identity + * message events before validation; other malformed messages reject before + * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ @@ -1052,8 +1054,9 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached - * values with deeply frozen identified messages, so observers cannot mutate message - * identity/content or backend-owned state. Malformed identified messages reject. + * values with upgraded, deeply frozen identified messages, so observers + * cannot mutate message identity/content or backend-owned state. Other + * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 311f7fc592..30ff0534ca 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -518,11 +518,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen; malformed identified messages reject before any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen. Coordinator-backed implementations upgrade supported pre-identity\n * message events before validation; other malformed messages reject before\n * any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with deeply frozen identified messages, so observers cannot mutate message\n * identity/content or backend-owned state. Malformed identified messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, { signature: 'abstract list(signal?: AbortSignal): Promise', diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bf3e91b078..f133f1979c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -77,6 +77,65 @@ function throwUnknown(value: unknown): never { } describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => { + it('resumes a session persisted before messages gained identities', async () => { + const sessionId = SessionId('pre-identity-resume') + const first = await persistentHarness(new MockAdapter([])) + await first.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + }) + await first.ctx.sessionPersistence.append(sessionId, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'old question' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 4, + data: { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'old answer' }], + provenance: { provider: 'mock', model: 'mock' }, + }, + surfaceOp: 'append', + }, + { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as unknown as SessionEvent[]) + await first.ctx.fiber.dispose() + + const ctx = await mountPersistentHarness(first.root, new MockAdapter([textResponse('new answer')])) + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(handle.agent.session.deriveMessages()).toMatchObject([ + { id: `legacy-message:${sessionId}:1`, role: 'user' }, + { id: `legacy-message:${sessionId}:3`, role: 'assistant' }, + ]) + + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'new question' }], + source: { kind: 'user' }, + })) + await waitForIdle(ctx, handle.agent) + expect(handle.agent.session.deriveMessages()).toHaveLength(4) + expect(handle.agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + await handle.dispose() + await ctx.fiber.dispose() + }) + it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => { const sessionId = SessionId('unknown-resume-failure-s') const root = await persistSession(sessionId) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c9bf568a38..9fb097d9fb 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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 packages/core/session/README.md -README.md: 40516d12180de9c30efd40fdffa873da20ddacb3 -README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7 +README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412 +README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 40516d1218..a9b6905dcf 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -142,5 +142,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). -- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 43842643a3..f1a5e97e32 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -142,5 +142,5 @@ - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index 0e2cecaada..184f29e3e8 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/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 packages/session-persistence/session-persistence/README.md -README.md: 08d8adac8040747a6dac01dbc41525073f17060c -README.zh.md: 7676f27a1aa934eb3472e1b32b9ecd55d460fb63 +README.md: 3617305d0343ab4c0d9d802669a3c4f964271dc7 +README.zh.md: ffa86b0093331306d524a590364fac527a2e5071 diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 08d8adac80..3617305d03 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -13,8 +13,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed messages, and unknown `version` reject. | -| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | +| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | +| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -33,6 +33,8 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Backend reads normalize pre-identity `user/message`, `assistant/message`, `tool/result`, and `steering/message` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise. + When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index 7676f27a1a..ffa86b0093 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -13,8 +13,8 @@ | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | -| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的消息和未知 `version` 会被拒绝。 | -| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | +| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | +| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | @@ -33,6 +33,8 @@ 崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message`、`assistant/message`、`tool/result` 以及 steering(中途引导)对应的 `steering/message` 载荷。每条导入消息都会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load`、`inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 + 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方的精确信号传入后端发现,使观察者可在不脱离该工作的情况下取消。 diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 878dddcb42..2d4d0290a9 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -146,10 +146,142 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): } } -/** Materialize stored events as validated snapshots with immutable messages. */ +/** Return an object record without widening arrays into message payloads. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] + +/** Mint the stable import identity for a message persisted before identities existed. */ +function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { + return `legacy-message:${id}:${seq}` as PersistedMessageId +} + +/** Read a replacement target while leaving malformed surface metadata to the session validator. */ +function replacementStart(event: SessionEvent): number | undefined { + const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + return op?.['op'] === 'replace' && typeof op['start'] === 'number' + ? op['start'] + : undefined +} + +/** + * Upgrade one pre-identity message event into the current wrapper shape. + * Current-looking malformed events remain untouched so validation rejects them + * instead of disguising corruption as legacy data. + */ +function migrateLegacyMessageEvent( + event: SessionEvent, + id: SessionId, + messageIds: ReadonlyMap, +): SessionEvent { + const data = asRecord(event.data) + if (data === undefined) return event + switch (event.type) { + case 'user/message': { + if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') + || Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + return { + ...event, + data: { + ...data, + id: legacyMessageId(id, event.seq), + role: 'user', + }, + } as SessionEvent + } + case 'assistant/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event + const { content, provenance, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'assistant', + content, + source: { + ...asRecord(provenance), + kind: 'model', + }, + }, + }, + } as SessionEvent + } + case 'tool/result': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') + || !Object.hasOwn(data, 'isError')) return event + const { callId, content, isError, ...eventData } = data + const inheritedId = replacementStart(event) + return { + ...event, + data: { + ...eventData, + message: { + id: inheritedId === undefined + ? legacyMessageId(id, event.seq) + : messageIds.get(inheritedId), + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content, + isError, + }], + source: { + kind: 'tool', + callId, + }, + }, + }, + } as SessionEvent + } + case 'steering/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + const { content, source, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'user', + content, + source, + }, + }, + } as SessionEvent + } + default: + return event + } +} + +/** Read the identified message carried by one validated current event. */ +function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { + const data = asRecord(event.data) + const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined +} + +/** Materialize stored events as upgraded, validated snapshots with immutable messages. */ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { assertSupportedEvents(events, id) - return events.map(snapshotSessionEvent) + const messageIds = new Map() + return events.map((event) => { + const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(event, id, messageIds)) + const messageId = eventMessageId(snapshot) + if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) + return snapshot + }) } /** @@ -526,7 +658,7 @@ export class PersistenceCoordinator { /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ if (stored === undefined) return false this.assertStoredId(id, stored.meta) - return seedCoversPrefix(seed, stored.events.slice(0, cursor)) + return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor)) } /** @@ -614,19 +746,19 @@ export class PersistenceCoordinator { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } this.assertVersion(meta) - assertSupportedEvents(events, session.header.id) - if (!seedCoversPrefix(seed, events)) { + const storedEvents = snapshotStoredEvents(events, session.header.id) + if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } // Truncate-only repair (no closers): the open turn is NOT closed here. if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) this.states.set(session.header.id, { meta: { ...meta }, - cursor: events.length, + cursor: storedEvents.length, materialized: true, owner: session, }) - const suffix = seed.slice(events.length) + const suffix = seed.slice(storedEvents.length) if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 261b26d364..0af538194a 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -93,7 +93,9 @@ export abstract class SessionPersistence extends Service { * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply - * frozen; malformed identified messages reject before any stored event is returned. + * frozen. Coordinator-backed implementations upgrade supported pre-identity + * message events before validation; other malformed messages reject before + * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ @@ -103,8 +105,9 @@ export abstract class SessionPersistence extends Service { * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached - * values with deeply frozen identified messages, so observers cannot mutate message - * identity/content or backend-owned state. Malformed identified messages reject. + * values with upgraded, deeply frozen identified messages, so observers + * cannot mutate message identity/content or backend-owned state. Other + * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index d63cb857a2..1de20528b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -13,8 +13,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -45,6 +45,80 @@ function send(session: Session, events: readonly SessionEvent[]): void { appendLog(session, events) } +/** A valid persisted log from immediately before messages gained wrappers and identities. */ +function legacyMessageLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 4, + data: { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], + provenance: { provider: 'mock', model: 'mock' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/call', + seq: 4, + time: 5, + data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' }, + }, + { + type: 'tool/result', + seq: 5, + time: 6, + data: { + turn: 1, + step: 1, + callId: 'call-1', + content: [{ type: 'text', text: 'full result' }], + isError: false, + }, + sourceEventSeqs: [4], + surfaceOp: 'append', + }, + { + type: 'steering/message', + seq: 6, + time: 7, + data: { + turn: 1, + content: [{ type: 'text', text: 'continue' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/result', + seq: 7, + time: 8, + data: { + turn: 1, + step: 1, + callId: 'call-1', + content: [{ type: 'text', text: 'pruned' }], + isError: false, + }, + sourceEventSeqs: [5], + surfaceOp: { op: 'replace', start: 5, end: 5 }, + }, + { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as unknown as SessionEvent[] +} + /** A live session created inside its OWN fiber, so it survives a backend reload. */ async function liveSessionInFiber( ctx: Context, id: string, cwd: string | undefined, @@ -269,6 +343,48 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('loads pre-identity message logs into resumable current sessions', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('legacy-message-load') + await ctx.sessionPersistence.create(meta(id, WORK)) + await ctx.sessionPersistence.append(id, legacyMessageLog()) + + for (const snapshot of [ + await ctx.sessionPersistence.inspect(id), + await ctx.sessionPersistence.load(id), + ]) { + const messages = snapshot.events.flatMap((event) => { + if (event.type === 'user/message') return [event.data] + if (event.type === 'assistant/message' + || event.type === 'tool/result' + || event.type === 'steering/message') return [event.data.message] + return [] + }) + expect(messages.map(message => message.id)).toEqual([ + `legacy-message:${id}:1`, + `legacy-message:${id}:3`, + `legacy-message:${id}:5`, + `legacy-message:${id}:6`, + `legacy-message:${id}:5`, + ]) + expect(messages.every(message => Object.isFrozen(message))).toBe(true) + + const resumed = new Session(id, snapshot.events, snapshot.meta) + expect(resumed.deriveMessages().map(message => message.id)).toEqual([ + `legacy-message:${id}:1`, + `legacy-message:${id}:3`, + `legacy-message:${id}:5`, + `legacy-message:${id}:6`, + ]) + } + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('rejects malformed persisted message events before returning them', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) @@ -292,6 +408,31 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< .rejects.toThrow('message must have role "user"') await expect(ctx.sessionPersistence.load(id)) .rejects.toThrow('message must have role "user"') + + for (const type of ['tool/result', 'steering/message'] as const) { + const malformedId = SessionId(`invalid-${type}`) + await ctx.sessionPersistence.create(meta(malformedId, WORK)) + await ctx.sessionPersistence.append(malformedId, [{ + type, + seq: 0, + time: 1, + surfaceOp: 'append', + data: { message: null }, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.inspect(malformedId)) + .rejects.toThrow('lacks an identified message') + } + + const pluginId = SessionId('non-object-plugin-event') + await ctx.sessionPersistence.create(meta(pluginId, WORK)) + await ctx.sessionPersistence.append(pluginId, [{ + type: 'plugin/test', + seq: 0, + time: 1, + data: null, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.inspect(pluginId)) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) } finally { await fiber.dispose() await fix.cleanup() From a79de44c3b470ebc8cec301c4b3c8f1c087e0fd7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:41:58 +0800 Subject: [PATCH 31/61] feat(session-persistence): readFrom(seq) primitive for watermark tail reads SessionPersistence grows readFrom(id, fromSeq, signal?): the non-mutating read-from-seq primitive for checkpoint consumers (the persisted projection cache folds only the tail past its watermark). Coordinator owns validation, per-id serialization, and the sequential fallback (loadStored + forward skip); SQLite implements the optional seek-capable loadStoredFrom hook (WHERE seq >= ?), JSONL stays sequential by contract. Contract suite covers suffix exactness, empty-tail, non-mutation, and cancellation; seam README (both languages) documents the method and the hook. --- .../tests/session-checkpoint-policy.spec.ts | 3 + .../session-persistence-jsonl/src/index.ts | 6 ++ .../session-persistence-sqlite/src/index.ts | 26 ++++++- .../session-persistence-sqlite/src/schema.ts | 10 ++- .../session-persistence/README.i18n.yaml | 4 +- .../session-persistence/README.md | 2 + .../session-persistence/README.zh.md | 2 + .../session-persistence/src/coordinator.ts | 73 +++++++++++++++++++ .../session-persistence/src/index.ts | 22 +++++- .../session-persistence/tests/contract.ts | 37 ++++++++++ .../tests/persistence.spec.ts | 4 + .../session-query-sqlite/tests/sqlite.spec.ts | 5 ++ .../session-query/tests/session-query.spec.ts | 5 ++ .../session-query/tests/tracing.spec.ts | 5 ++ 14 files changed, 196 insertions(+), 8 deletions(-) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 7a2081bd60..4d128c0012 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -22,6 +22,9 @@ class TestPersistence extends SessionPersistence { inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return Promise.reject(new Error('not used')) } + readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return Promise.reject(new Error('not used')) + } list(): Promise { return Promise.resolve([]) } listSnapshots(): Promise { return Promise.resolve([]) } } diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a312b94a40..4ef8e30ebf 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -134,6 +134,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id, signal) } + // JSONL is sequential media: no loadStoredFrom hook, so the coordinator + // parses the stored prefix (both encodings) and skips forward to fromSeq. + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index f771b9e3a7..60abc93729 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -16,7 +16,7 @@ import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + type StoredPrefix, type StoredSuffix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -161,6 +161,10 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id, signal) } + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -171,6 +175,26 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.readPrefix(id, signal) } + /** + * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the + * read scales with the suffix, not the log. Torn rows past the preserved + * region are dropped, never repaired (non-mutating read). + */ + async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + if (row === undefined) return undefined + const meta = rowToMeta(row) + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .all(id, fromSeq) as unknown as EventRow[] + signal?.throwIfAborted() + const { preserved } = scanRows(eventRows, fromSeq) + return { meta, events: preserved } + } + /** * Read a session's row + ordered events into a {@link StoredPrefix}. The * torn-tail marker is the seq from which a never-committed tail must be deleted diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..c3e4931f81 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -213,10 +213,12 @@ export function rowToEvent(row: EventRow): SessionEvent { * the committed region rejects. * * @param rows - one session's event rows, ordered by seq ascending. + * @param base - the seq the first row is expected to carry; `0` for a whole + * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. */ -export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } { +export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } { // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole. // (The seq/type COLUMNS are always present even when `data` is corrupt.) interface Parsed { ok: boolean; event?: SessionEvent } @@ -244,8 +246,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`) break // torn tail fragment after the last turn/end — stop, tolerate } - if (p.event.seq !== i) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`) + if (p.event.seq !== base + i) { + if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`) break // gap after the last turn/end — torn tail, stop } preserved.push(p.event) @@ -253,5 +255,5 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] // Any rows past the preserved prefix are a never-committed torn tail; their // first seq is the deletion point for load's physical repair. - return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved } + return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved } } diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index 184f29e3e8..39462346b3 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/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 packages/session-persistence/session-persistence/README.md -README.md: 3617305d0343ab4c0d9d802669a3c4f964271dc7 -README.zh.md: ffa86b0093331306d524a590364fac527a2e5071 +README.md: a8a4f14c8613a7e51bcf467e816b7f7bdb7ea80b +README.zh.md: 369e8a01b8ac411ed9acfbac86b9b34db8037c1f diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 3617305d03..a8a4f14c86 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,6 +15,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -45,6 +46,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | | `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list(signal?)` | List all stored metadata, observing optional cancellation. | diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index ffa86b0093..369e8a01b8 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -15,6 +15,7 @@ | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀;顺序后端(JSONL)仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量,不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | @@ -45,6 +46,7 @@ |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | | `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 | | `list(signal?)` | 列出全部已存储元数据,观察可选取消。 | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 2d4d0290a9..51de7f9ee3 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,6 +25,17 @@ export interface StoredPrefix { tornMarker?: TornMarker } +/** + * A stored session's header plus the events at or past a requested seq — the + * return shape of the optional seek-capable + * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no + * torn marker: there is nothing to repair. + */ +export interface StoredSuffix { + meta: SessionHeader + events: SessionEvent[] +} + /** * The storage seam between {@link PersistenceCoordinator} and a concrete * backend: the minimal set of durable primitives the orchestration calls. A @@ -50,6 +61,22 @@ export interface PersistenceBackend { */ loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> + /** + * Optional seek-capable suffix read behind the service's `readFrom`: return + * the header plus the stored events with `seq >= fromSeq` without reading + * the whole log. A backend whose medium can address events by seq (SQLite) + * implements this so `readFrom` scales with the suffix; sequential backends + * omit it and the coordinator falls back to {@link loadStored} plus a + * forward skip. Non-mutating (no truncation, no closers). Validation of the + * region strictly below `fromSeq` is limited to seq contiguity — the + * service contract scopes this read to the suffix. + * @param id - persisted session id to resolve. + * @param fromSeq - first event seq to include (non-negative safe integer, + * validated by the coordinator before this hook runs). + * @param signal - optional cancellation for backend read work. + */ + loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise + /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -457,6 +484,52 @@ export class PersistenceCoordinator { } } + /** + * Read the stored events from `fromSeq` onward, detached and non-mutating + * (the read-from-seq primitive behind the service's `readFrom`). Runs on + * the same per-id chain as writes; a backend with the seek-capable + * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix, + * every other backend reads its stored prefix and skips forward here. + * @param id - persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns stored header and the valid stored events with `seq >= fromSeq`. + */ + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { + return Promise.reject(new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`)) + } + const retired = Promise.resolve(this.retirements.get(id)) + const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) + return waited.then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal)) + } + + private async readFromCore( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + signal?.throwIfAborted() + if (this.backend.loadStoredFrom !== undefined) { + let suffix: StoredSuffix | undefined + try { + suffix = await this.backend.loadStoredFrom(id, fromSeq, signal) + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + throw error + } + signal?.throwIfAborted() + if (suffix === undefined) throw new Error(`session "${id}" not found`) + this.assertStoredId(id, suffix.meta) + this.assertVersion(suffix.meta) + assertSupportedEvents(suffix.events, id) + return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) } + } + const whole = await this.inspectCore(id, signal) + // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. + return { meta: whole.meta, events: whole.events.slice(fromSeq) } + } + 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`) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 0af538194a..5d4f5b616e 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -23,7 +23,7 @@ export interface SessionPersistenceSnapshot { // The backend-agnostic write-path orchestration first-party backends compose. export { PersistenceCoordinator } from './coordinator.ts' -export type { PersistenceBackend, StoredPrefix } from './coordinator.ts' +export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts' declare module 'cordis' { interface Context { @@ -114,6 +114,26 @@ export abstract class SessionPersistence extends Service { */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ + abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): + Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 24a76b02cd..9ff82101d7 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -289,6 +289,43 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const m = meta('read-from', '/work') + const log = oneTurnLog() + await persistence.create(m) + await persistence.append(m.id, log) + + const whole = await persistence.readFrom(m.id, 0) + expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' }) + expect(whole.events).toEqual(log) + + const suffix = await persistence.readFrom(m.id, 3) + expect(suffix.events).toEqual(log.slice(3)) + expect(suffix.events[0]?.seq).toBe(3) + + // At/past the stored end: an empty tail, never an error. + await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] }) + await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] }) + + // Non-mutating: an interrupted-turn log is served as stored, no closers. + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ]) + const tail = await persistence.readFrom(m.id, 6) + expect(tail.events.map(event => event.type)).toEqual(['turn/start']) + + await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found') + await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer') + await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer') } finally { await dispose() } diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 2bb49948fb..966060dfbb 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -99,6 +99,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.inspect(id, signal) } + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index f7d2b3b352..689bbad618 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -149,6 +149,11 @@ class TestPersistence extends SessionPersistence { return structuredClone(entry) } + async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id, signal) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + async list(): Promise { TestPersistence.listStarted?.() await TestPersistence.listGate 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 38a72c55a8..c22bbcd98a 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -96,6 +96,11 @@ class TestPersistence extends SessionPersistence { return Promise.resolve(result) } + async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id, signal) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + list(signal?: AbortSignal): Promise { TestPersistence.listCalls += 1 TestPersistence.listSignals.push(signal) diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 2b44b8a46e..21e1a9089b 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -76,6 +76,11 @@ class TracePersistence extends SessionPersistence { return Promise.resolve(structuredClone(entry)) } + async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + list(): Promise { TracePersistence.listCalls += 1 if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure) From fc4b573ff0c81467ddf7114c9db6a9e90027ae3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:12:39 +0800 Subject: [PATCH 32/61] feat(session-projection): checkpoint/restore faces for the persisted projection cache The registry grows the state-level read ladder the persisted cache builds on: checkpoint(session) snapshots every unit's {stateVersion, observedSeq, state} row from the watermark cache; restoreFloor(checkpoint) anchors the tail read one event below the lowest usable watermark (so a shrunk log is provable); restore(checkpoint, events, baseSeq) refolds each unit from its usable row (or from init over a full read), rejects rows a tail read cannot fix (version mismatch / overreach with baseSeq > 0 => re-read from 0), and returns both the snapshot and the refreshed rows for durable write-back. ProjectionCheckpointRow/ProjectionCheckpoint are the persisted-row types minus the record keys. --- .../session-projection/src/index.ts | 128 ++++++++++++++++++ .../session-projection/tests/registry.spec.ts | 122 +++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index e43a03d38b..c2974b565d 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -97,6 +97,27 @@ export interface ProjectionSnapshot { values: Partial } +/** + * One unit's checkpoint: its internal state (plain JSON by the unit + * contract), the seq of the last event folded into it, and the + * `stateVersion` that produced it — the persisted projection-cache row + * `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer + * keys. A row is never authoritative, only a fold shortcut: `restore` + * discards it on a `stateVersion` mismatch or when it claims events past the + * stored log end. + */ +export interface ProjectionCheckpointRow { + /** The registering unit's `stateVersion` at fold time. */ + stateVersion: number + /** Seq of the last event folded into `state`; -1 for the empty log. */ + observedSeq: number + /** The unit's internal state — plain JSON per the unit contract. */ + state: unknown +} + +/** Checkpoint rows keyed by projection key (one session's persisted cache value). */ +export type ProjectionCheckpoint = Record + /** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */ interface ErasedDefinition { key: string @@ -206,6 +227,113 @@ export class SessionProjectionRegistry extends Service { return { asOfSeq: session.seq - 1, values: values } } + /** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {stateVersion, observedSeq, state})` part + * of the durable `(sessionId, key, stateVersion, observedSeq, state)` + * rows. States are the units' live references — plain JSON by the unit + * contract, treated as immutable; a durable writer snapshots them at its + * own boundary. + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ + checkpoint(session: Session): ProjectionCheckpoint { + const rows: ProjectionCheckpoint = {} + for (const registration of this.registrations.values()) { + const cell = this.cellFor(registration, session) + rows[registration.def.key] = { + stateVersion: registration.def.stateVersion, + observedSeq: cell.observedSeq, + state: cell.state, + } + } + return rows + } + + /** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `stateVersion` matches the live unit; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ + restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined { + let floor: number | undefined + for (const registration of this.registrations.values()) { + const row = checkpoint[registration.def.key] + const need = row !== undefined && row.stateVersion === registration.def.stateVersion + ? Math.max(row.observedSeq + 1, 0) + : 0 + floor = floor === undefined ? need : Math.min(floor, need) + } + return floor === undefined ? undefined : Math.max(floor - 1, 0) + } + + /** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `stateVersion` matches the live unit, it does not predate `baseSeq` + * (`observedSeq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ + restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): + { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { + const endSeq = events.at(-1)?.seq ?? baseSeq - 1 + const values: Record = {} + const refreshed: ProjectionCheckpoint = {} + for (const registration of this.registrations.values()) { + const def = registration.def + const row = checkpoint[def.key] + const usable = row !== undefined + && row.stateVersion === def.stateVersion + && row.observedSeq >= baseSeq - 1 + && row.observedSeq <= endSeq + if (!usable && baseSeq > 0) { + throw new Error( + `session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: ` + + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', + ) + } + let state = usable ? row.state : def.init() + const from = usable ? row.observedSeq : baseSeq - 1 + for (const event of events) { + if (event.seq > from) state = def.apply(state, event) + } + values[def.key] = def.schema.parse(def.view(state)) + refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } + } + return { + snapshot: { asOfSeq: endSeq, values: values as ProjectionSnapshot['values'] }, + checkpoint: refreshed, + } + } + /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell { let state = def.init() diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 06d7947caf..3c069f91d3 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -169,6 +169,128 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) + it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => { + const { ctx, session } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) + const markEvent = mark(session, ['a']) + const rows = ctx.sessionProjections.checkpoint(session) + expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } }) + expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 }) + // Empty log: init-derived state at watermark -1. + const fresh = ctx.sessions.create() + expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + }) + + it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { + const { ctx } = await harness() + expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + expect(ctx.sessionProjections.restoreFloor({})).toBe(0) + // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 + // (one below the first needed seq 6), so the read proves seq 5 still exists. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } }, + 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + })).toBe(5) + // A version-mismatched row forces that key back to a full refold. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } }, + 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + })).toBe(0) + // A fresh (-1) row still needs the whole tail from 0. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 1, observedSeq: -1, state: null }, + 'test/count': { stateVersion: 1, observedSeq: -1, state: 0 }, + })).toBe(0) + }) + + it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const tail: SessionEvent[] = [ + { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } } as SessionEvent, + { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + // marks row usable (watermark 2, tail starts at 3); count row mismatched — but + // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read. + expect(() => ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } }, + 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + }, tail, 3)).toThrow(/re-read from seq 0/) + // The full-log re-read (baseSeq 0) refolds the mismatched key from init. + const full: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } } as SessionEvent, + { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } } as SessionEvent, + ...tail, + ] + const { snapshot, checkpoint } = ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } }, + 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + }, full, 0) + expect(snapshot.asOfSeq).toBe(4) + expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) + expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events + // The refreshed rows sit at the served cut, ready for a durable write-back. + expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } }) + expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 }) + }) + + it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const rows = { + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, + 'test/count': { stateVersion: 1, observedSeq: 2, state: 3 }, + } + const tail: SessionEvent[] = [ + { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) + expect(snapshot.asOfSeq).toBe(4) + // marks already covers the tail (watermark 4): nothing re-applied. + expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) + // count folds exactly seqs 3 and 4 on top of its checkpoint. + expect(snapshot.values['test/count']).toBe(5) + + // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. + const { snapshot: current } = ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, + 'test/count': { stateVersion: 1, observedSeq: 4, state: 5 }, + }, [], 5) + expect(current.asOfSeq).toBe(4) + expect(current.values['test/count']).toBe(5) + }) + + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(countUnit()) + const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } } + // The anchored floor sits ON the watermark, so the tail read must return + // at least seq 9 from an intact log… + const floor = ctx.sessionProjections.restoreFloor(rows) + expect(floor).toBe(9) + // …an intact log serves the anchor event and the checkpoint stands as-is. + const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } + expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10) + // …while a log crash-repaired down to fewer events returns an empty tail: + // the row overreaches the proven end and a tail read cannot fix this key. + expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) + // The full re-read discards the overreaching row and refolds from init. + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) + expect(snapshot.asOfSeq).toBe(1) + expect(snapshot.values['test/count']).toBe(2) + }) + it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register({ From c330c1cd3ec627a2020ff6f80fad8765e358fec5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:26:19 +0800 Subject: [PATCH 33/61] =?UTF-8?q?feat:=20dsh-session-projection-cache=20?= =?UTF-8?q?=E2=80=94=20durable=20projection=20checkpoints=20and=20the=20co?= =?UTF-8?q?ld-read=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package on the domain data form: one session_projcache record per session (key → {stateVersion, observedSeq, state}), landing beside workspace.json under the shipped json backend. Write policy: two mandatory points (turn/end + session disposal) with count/interval throttling between them (both Config fields required — flush cadence is a deployment choice); every background write is fail-soft (log + stay stale, self-heal on the next write or cold read). coldSnapshot(id) runs the read ladder — cached rows + persistence readFrom from the registry's anchored restore floor + registry restore + fail-soft write-back — detecting crash-repair-shrunk logs via the one-below anchor and degrading to a single full re-read. Mounted in apps/cli/cordis.yml (writeEveryEvents 200 / writeIntervalMs 5000). --- apps/cli/cordis.yml | 10 + apps/cli/package.json | 1 + packages/session-projection/README.md | 1 + .../session-projection-cache/README.md | 60 +++++ .../session-projection-cache/package.json | 52 ++++ .../session-projection-cache/src/index.ts | 237 ++++++++++++++++ .../session-projection-cache/src/invariant.ts | 35 +++ .../session-projection-cache/src/spec.ts | 51 ++++ .../tests/cache.spec.ts | 255 ++++++++++++++++++ .../session-projection-cache/tsconfig.json | 39 +++ pnpm-lock.yaml | 37 +++ tsconfig.host.json | 1 + 12 files changed, 779 insertions(+) create mode 100644 packages/session-projection/session-projection-cache/README.md create mode 100644 packages/session-projection/session-projection-cache/package.json create mode 100644 packages/session-projection/session-projection-cache/src/index.ts create mode 100644 packages/session-projection/session-projection-cache/src/invariant.ts create mode 100644 packages/session-projection/session-projection-cache/src/spec.ts create mode 100644 packages/session-projection/session-projection-cache/tests/cache.spec.ts create mode 100644 packages/session-projection/session-projection-cache/tsconfig.json diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 0dbd6c839e..bf0ab30598 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -116,6 +116,16 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Persisted projection cache: durable per-session checkpoints of every +# registered projection unit (json backend → ./.storages/session_projcache.json, +# beside workspace.json), throttled between the two mandatory points +# (turn/end + detach), serving cold listings without full-log loads. +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 7d984ee1b1..6aa491221e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/session-projection/README.md b/packages/session-projection/README.md index 81c67d56e1..ae80a90570 100644 --- a/packages/session-projection/README.md +++ b/packages/session-projection/README.md @@ -7,3 +7,4 @@ Session-projection capability family: the seam through which domain host plugins | Package | ctx key | Role | |---|---|---| | [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionDefinition` unit contract, and the eagerly driven registry carriers read synchronously | +| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | Persisted projection cache: durable per-session unit checkpoints over the domain data form, throttled write-behind with mandatory turn/end + detach points, and the cold-read ladder (cache row + persistence tail replay) | diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md new file mode 100644 index 0000000000..8bb8efe4cd --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-session-projection-cache + +The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). + +A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: + +- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. +- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. +- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. + +## Write policy + +Two mandatory points, throttled in between: + +| Trigger | Nature | +|---|---| +| `turn/end` | Mandatory — the turn-final value is what cold reads want. | +| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. | +| `writeEveryEvents` committed events | Config throttle (count). | +| `writeIntervalMs` since the first dirty event | Config throttle (interval). | + +Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml. + +## Cold read (`coldSnapshot(id, signal?)`) + +The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`. + +`write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment). + +## Composition + +```yaml +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 +``` + +Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them). + +## Model Experience + +### What the model sees + +Nothing. The cache is a host read-model accelerator; no prompt, schema, or tool surface. + +### Token effect + +Zero. + +### KV Cache effect + +None — no request content changes. + +## Known Limitations and Deferred Work + +- **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself. +- **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window. +- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates. diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json new file mode 100644 index 0000000000..5080713412 --- /dev/null +++ b/packages/session-projection/session-projection-cache/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-projection-cache", + "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-storage-domain": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts new file mode 100644 index 0000000000..c9336dd848 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -0,0 +1,237 @@ +/** + * Persisted projection cache (`ctx.sessionProjectionCache`): durable + * checkpoints of every registered projection unit's state, one record per + * session on the domain data form (`session_projcache` domain — the shipped + * json backend lands it beside `workspace.json`). The cache is a fold + * shortcut, never an authority: a row is possibly stale (its `observedSeq` + * says how stale) but never wrong, so every write path is fail-soft (a lost + * write costs a longer tail replay on the next cold read) and a + * `stateVersion` mismatch discards the row instead of migrating it. Design + * authority: the session-projection RFC + * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + * @module @deepseek-ai/dsh-session-projection-cache + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +// Empty type import: applies the package's cordis Context merge +// (`ctx.sessionPersistence`), which this service reads on the cold path. +import type {} from '@deepseek-ai/dsh-session-persistence' +import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { projectionCacheDomainSpec } from './spec.ts' +import type { CheckpointRecord } from './spec.ts' + +export { checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' +export type { CheckpointRecord } from './spec.ts' + +declare module 'cordis' { + interface Context { + sessionProjectionCache: SessionProjectionCache + } +} + +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} + +export const Config: z = z.object({ + writeEveryEvents: z.natural().min(1).required(), + writeIntervalMs: z.natural().min(1).required(), +}) + +/** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */ +interface DirtyState { + /** Committed events since the last durable write. */ + pending: number + /** Interval trigger armed at the first dirty event after a clean write. */ + timer: ReturnType | undefined +} + +/** + * The persisted projection cache service. Opens the `session_projcache` + * domain at init, checkpoints live sessions on a throttled write-behind + * (count/interval triggers from {@link Config}) plus two mandatory points — + * `turn/end` and session disposal (the live-to-cold moment) — and serves the + * cold-read ladder: cached row, persistence `readFrom` tail, registry + * `restore`, durable write-back. Every durable write is fail-soft: failures + * log a warning and the cache self-heals on the next write or cold read. + */ +export class SessionProjectionCache extends Service { + static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions'] + + static Config: z = Config + + private table?: KvTable + private readonly dirty = new Map() + + constructor(ctx: Context, public config: Config) { + super(ctx, 'sessionProjectionCache') + } + + /** Open the domain and install the write-behind listeners. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec) + this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose') + this.table = domain.table('sessions') + this.installWritePath() + } + + /** + * The stored checkpoint rows for one session, or an empty checkpoint when + * none is stored. Synchronous from the domain's in-memory state. + * @param id - the session whose cached rows are read. + * @returns the persisted `key → row` checkpoint (possibly empty). + */ + checkpointOf(id: SessionId): ProjectionCheckpoint { + return this.requireTable().get(id)?.rows ?? {} + } + + /** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ + async write(session: Session): Promise { + const rows = this.ctx.sessionProjections.checkpoint(session) + this.markClean(session) + await this.put(session.id, rows) + } + + /** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ + async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { + const cached = this.checkpointOf(id) + const floor = this.ctx.sessionProjections.restoreFloor(cached) + if (floor === undefined) return { asOfSeq: -1, values: {} } + const persistence = this.ctx.sessionPersistence + let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } + const tail = await persistence.readFrom(id, floor, signal) + try { + restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) + } catch { + // The one recoverable restore failure: a row overreaching the stored + // log end (or predating the floor), detected by the registry. Both + // resolve identically — discard the cache and refold the full log. + const whole = await persistence.readFrom(id, 0, signal) + restored = this.ctx.sessionProjections.restore({}, whole.events, 0) + } + await this.putSoft(id, restored.checkpoint, 'cold-read write-back') + return restored.snapshot + } + + // --- write-behind (throttle + mandatory points) --- + + private installWritePath(): void { + // Every committed event advances the dirty counter; turn/end is a + // mandatory point (the durable value most reads want is the turn-final + // one), count/interval throttle the in-turn stream. + this.ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type === 'turn/end') { + void this.flushSoft(session, 'turn/end') + return + } + const state = this.dirty.get(session) ?? { pending: 0, timer: undefined } + this.dirty.set(session, state) + state.pending += 1 + if (state.pending >= this.config.writeEveryEvents) { + void this.flushSoft(session, 'count threshold') + return + } + state.timer ??= setTimeout(() => { + void this.flushSoft(session, 'interval') + }, this.config.writeIntervalMs) + }) + + // Detach (the live-to-cold moment): the second mandatory point. After + // this write the cold-read ladder serves the session from the cache. + // flushSoft's synchronous prefix reads and resets the dirty state, so + // dropping it (timer already cleared by markClean) right after is safe. + this.ctx.on('session/disposed', (session: Session) => { + void this.flushSoft(session, 'detach') + this.markClean(session) + this.dirty.delete(session) + }) + + // Clear pending timers with the plugin (their sessions outlive the cache). + this.ctx.effect(() => () => { + for (const state of this.dirty.values()) { + if (state.timer !== undefined) clearTimeout(state.timer) + } + this.dirty.clear() + }, 'sessionProjectionCache.timers') + } + + /** One fail-soft durable checkpoint: skip when clean, log on failure. */ + private async flushSoft(session: Session, trigger: string): Promise { + const state = this.dirty.get(session) + const mandatory = trigger === 'turn/end' || trigger === 'detach' + if (!mandatory && (state === undefined || state.pending === 0)) return + try { + await this.write(session) + } catch (error) { + this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`) + } + } + + /** Reset one session's dirty bookkeeping (its checkpoint is being written). */ + private markClean(session: Session): void { + const state = this.dirty.get(session) + if (state === undefined) return + state.pending = 0 + if (state.timer !== undefined) { + clearTimeout(state.timer) + state.timer = undefined + } + } + + /** Replace one session's stored record with a detached snapshot of `rows`. */ + private async put(id: SessionId, rows: ProjectionCheckpoint): Promise { + const detached = snapshotJsonValue(rows) + if (detached === undefined) { + throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)') + } + await this.requireTable().put(id, { rows: detached as CheckpointRecord['rows'] }) + } + + /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */ + private async putSoft(id: SessionId, rows: ProjectionCheckpoint, what: string): Promise { + try { + await this.put(id, rows) + } catch (error) { + this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`) + } + } + + private requireTable(): KvTable { + /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */ + if (this.table === undefined) throw new Error('session projection cache is not initialized') + return this.table + } +} + +export default SessionProjectionCache diff --git a/packages/session-projection/session-projection-cache/src/invariant.ts b/packages/session-projection/session-projection-cache/src/invariant.ts new file mode 100644 index 0000000000..a5a5515f22 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/invariant.ts @@ -0,0 +1,35 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`. + * @module @deepseek-ai/dsh-session-projection-cache/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache' + +/** Cordis companion plugin name. */ +export const name = 'session-projection-cache-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the cache's correctness relation (a stored row equals + * the registry fold at its `observedSeq`) is only checkable by re-running the + * fold over the persisted log — duplicating the implementation rather than + * detecting drift — and its staleness is by design (fail-soft writes). The + * durable boundary is already schema-validated by the storage-domain layer + * on every reopen, and the read ladder's version/watermark guards are proven + * by the package spec. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts new file mode 100644 index 0000000000..1cc4931032 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -0,0 +1,51 @@ +/** + * The session-projcache domain declaration: one `sessions` table keyed by + * {@link SessionId}, each record the full projection checkpoint for one + * session (`key → {stateVersion, observedSeq, state}` rows). The spec object + * is the single source of the domain's identity, version, and record schema; + * the storage-domain routing decides the medium (the shipped composition's + * json backend lands it at `/session_projcache.json`, beside + * `workspace.json`). + * @module @deepseek-ai/dsh-session-projection-cache/src/spec + */ + +import { z } from 'zod' +import { SessionId } from '@deepseek-ai/dsh-session' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' + +/** + * One persisted checkpoint row (the RFC's `(sessionId, key, stateVersion, + * observedSeq, state)` minus the two record keys). `state` is the unit's + * internal state — plain JSON by the unit contract; `z.json()` enforces that + * at the durable boundary. A row is never wrong, only possibly stale: + * `observedSeq` says exactly how stale, and a `stateVersion` mismatch + * discards it at read time (never a migration). + */ +export const checkpointRow = z.object({ + stateVersion: z.number().int().nonnegative(), + observedSeq: z.number().int().gte(-1), + state: z.json(), +}) + +/** + * One session's stored record: its checkpoint rows keyed by projection key. + * The whole record is replaced on every write (whole-value discipline — the + * registry checkpoint is always the complete per-session cut). + */ +export const checkpointRecord = z.object({ + rows: z.record(z.string(), checkpointRow), +}) + +/** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */ +export type CheckpointRecord = z.infer + +/** + * The session-projcache domain spec. Version bumps discard the whole medium + * (cache semantics: a stale or unreadable cache costs a longer tail replay, + * never a wrong value). + */ +export const projectionCacheDomainSpec = defineDomain({ + name: 'session_projcache', + version: 1, + tables: { sessions: domainTable(checkpointRecord) }, +}) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts new file mode 100644 index 0000000000..defeddfd32 --- /dev/null +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -0,0 +1,255 @@ +/** + * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach), + * count/interval throttling between them, fail-soft durability (a failed + * write logs and stays stale, never throws into the event path), and the + * cold-read ladder (cached row + readFrom tail + registry restore + + * write-back; version bump and shrunk-log rows degrade to a full re-read). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +import SessionProjectionCache from '../src/index.ts' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'cache-test/marks': { marks: string[] } + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'cache-test/mark': { marks: string[] } + } + + interface OutOfBandSessionEventMap { + 'cache-test/mark': true + } +} + +type MarksState = { marks: string[] } | null +const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({ + key: 'cache-test/marks', + schema: z.object({ marks: z.array(z.string()) }), + init: () => null, + apply: (state, event) => (event.type === 'cache-test/mark' ? (event as SessionEvent<'cache-test/mark'>).data : state), + view: state => state ?? { marks: [] }, + stateVersion, +}) + +/** A persistence double serving readFrom over a fixed per-id stored log. */ +function fakePersistence(logs: Map) { + const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { + const events = logs.get(String(id)) + if (events === undefined) throw new Error(`session "${id}" not found`) + return { + meta: { version: 0, id, createdAt: 0 }, + events: events.filter(event => event.seq >= fromSeq), + } + }) + return { readFrom } +} + +interface HarnessOptions { + pool?: MemoryMediaPool + config?: { writeEveryEvents: number; writeIntervalMs: number } + stateVersion?: number + logs?: Map +} + +const contexts: Context[] = [] + +async function harness(options: HarnessOptions = {}) { + const pool = options.pool ?? new MemoryMediaPool() + const logs = options.logs ?? new Map() + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(marksUnit(options.stateVersion)) + const persistence = fakePersistence(logs) + ctx.provide('sessionPersistence', persistence as never) + const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache } +} + +const mark = (session: Session, marks: string[]): SessionEvent => + session.append('cache-test/mark', { marks }) + +const endTurn = (session: Session): SessionEvent => + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + +/** The stored medium rows for one session id (undefined = never written). */ +function storedRows(pool: MemoryMediaPool, id: Session['id']) { + const record = pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as + { rows: Record } | undefined + return record?.rows +} + +/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */ +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('SessionProjectionCache write policy', () => { + it('writes a durable checkpoint at turn/end (mandatory point)', async () => { + const { ctx, pool } = await harness() + const session = ctx.sessions.create(SessionId('turn-end')) + mark(session, ['a']) + expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet + const end = endTurn(session) + await settle() + const rows = storedRows(pool, session.id) + expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } }) + }) + + it('writes at session disposal (detach, the live-to-cold moment)', async () => { + const { ctx, pool } = await harness() + // Sessions dispose with their owning fiber: create in a child plugin. + let session: Session | undefined + const owner = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('detach')) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + mark(session, ['live']) + await owner.dispose() + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] }) + }) + + it('flushes when the in-turn event count reaches the configured threshold', async () => { + const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } }) + const session = ctx.sessions.create(SessionId('count')) + mark(session, ['1']) + mark(session, ['2']) + await settle() + expect(storedRows(pool, session.id)).toBeUndefined() + mark(session, ['3']) + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] }) + }) + + it('flushes on the configured interval when the count threshold is not reached', async () => { + vi.useFakeTimers() + const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } }) + const session = ctx.sessions.create(SessionId('interval')) + mark(session, ['slow']) + await vi.advanceTimersByTimeAsync(249) + expect(storedRows(pool, session.id)).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + await vi.runAllTicks() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) + }) + + it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => { + const { ctx, pool } = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = ctx.sessions.create(SessionId('fail-soft')) + mark(session, ['x']) + pool.failNextWrites = 1 + endTurn(session) + await settle() + expect(storedRows(pool, session.id)).toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) + // Self-heal: the next mandatory point writes the current cut. + mark(session, ['y']) + endTurn(session) + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] }) + }) +}) + +describe('SessionProjectionCache cold read', () => { + const storedLog = (marks: string[][]): SessionEvent[] => { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] + for (const m of marks) { + events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } } as SessionEvent) + } + events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } }) + return events + } + + /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */ + function seedRow(pool: MemoryMediaPool, id: string, row: { stateVersion: number; observedSeq: number; state: unknown }): void { + pool.versions.set('session_projcache', 1) + pool.media.set('session_projcache', { + tables: new Map([['sessions', new Map([[id, { rows: { 'cache-test/marks': row } }]])]]), + global: null, + }) + } + + it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]]) + // A warm-era checkpoint at watermark 1 (only ['a'] folded). + seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } }) + const { cache, persistence, pool: samePool } = await harness({ pool, logs }) + const id = SessionId('cold') + const snapshot = await cache.coldSnapshot(id) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] }) + expect(snapshot.asOfSeq).toBe(3) + // The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0. + expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined) + // Write-back: the stored row advanced to the served cut. + expect(storedRows(samePool, id)?.['cache-test/marks']) + .toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } }) + }) + + it('discards a version-mismatched row and refolds the full log', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['bumped', storedLog([['a']])]]) + seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } }) + const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 }) + const snapshot = await cache.coldSnapshot(SessionId('bumped')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + // Mismatch pulls the floor to 0: one full read, no second pass needed. + expect(persistence.readFrom).toHaveBeenCalledTimes(1) + expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined) + }) + + it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2 + seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } }) + const { cache, persistence } = await harness({ pool, logs }) + const snapshot = await cache.coldSnapshot(SessionId('shrunk')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + expect(snapshot.asOfSeq).toBe(2) + // Anchored tail read (floor 9) came back empty -> full re-read from 0. + expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined) + expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined) + }) + + it('write-back failure is contained: the snapshot is still served', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['soft', storedLog([['a']])]]) + const { ctx, cache } = await harness({ pool, logs }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + pool.failNextWrites = 1 + const snapshot = await cache.coldSnapshot(SessionId('soft')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed')) + }) + + it('rejects for a session with no persisted log', async () => { + const { cache } = await harness() + await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') + }) +}) diff --git a/packages/session-projection/session-projection-cache/tsconfig.json b/packages/session-projection/session-projection-cache/tsconfig.json new file mode 100644 index 0000000000..baba6c805b --- /dev/null +++ b/packages/session-projection/session-projection-cache/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../session-projection" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..7e189366cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../packages/session-projection/session-projection-cache '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -3460,6 +3463,40 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-projection/session-projection-cache: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-query/session-query: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b4a30f0b6..d211330396 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -58,6 +58,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-projection/session-projection" }, + { "path": "./packages/session-projection/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/session-query/tool-session-query" }, From 003b22a1572569a3cf77efde193ade8cfebc75a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:38:15 +0800 Subject: [PATCH 34/61] =?UTF-8?q?feat(apiproxy):=20projection=20column=20o?= =?UTF-8?q?n=20session.list=20=E2=80=94=20cold=20titles=20with=20zero=20lo?= =?UTF-8?q?g=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionSummary grows an optional projections column (whole value per key, same passthrough posture as the history-tail block): attached rows cut the live registry watermark cache; cold rows view the persisted projection cache's stored rows via the new registry viewCheckpoint face (version- matching keys only, zero I/O) — the RFC's motivating scenario, every session's title across a listing without loading one event log. The column is fail-soft and absence-coded: no registry, no cache row, or a throwing read serve the row without the column, never breaking the listing. --- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 40 +++++++++- .../host/apiproxy/src/api/sessions.schema.ts | 11 +++ packages/host/apiproxy/src/api/sessions.ts | 11 +++ .../tests/api-proxy-projections.spec.ts | 73 ++++++++++++++++++- packages/host/apiproxy/tsconfig.json | 3 + .../session-projection-cache/src/index.ts | 16 +++- .../session-projection/src/index.ts | 21 ++++++ .../session-projection/tests/registry.spec.ts | 13 ++++ pnpm-lock.yaml | 3 + 10 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ab632199bd..a3e0cfac5d 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56f488857f..486a528a78 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -29,7 +29,9 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). +import type {} from '@deepseek-ai/dsh-session-projection-cache' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' @@ -297,6 +299,26 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u return registry.snapshot(agent.session) } +/** + * The projection column of one session.list row, fail-soft: attached + * sessions cut the registry's live watermark cache; cold sessions view the + * persisted projection cache's stored rows (zero log loads either way — the + * listing use case the cache exists for). Any failure — and an empty value + * set — yields an absent column: a listing without projections is degraded, + * never broken. + */ +function listProjectionsFor(ctx: Context, id: SessionId, session: Session | undefined): Partial | undefined { + try { + const values = session !== undefined + ? ctx.get('sessionProjections')?.snapshot(session).values + : ctx.get('sessionProjectionCache')?.cachedValues(id) + return values !== undefined && Object.keys(values).length > 0 ? values : undefined + } catch (error) { + ctx.logger.warn(`session.list: projection column for "${id}" failed (serving the row without it): ${String(error)}`) + return undefined + } +} + /** * Thrown by the cold-resume path when the id names no servable session * (absent from the store, or a pre-project legacy log without a cwd). @@ -654,13 +676,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async list(request) { const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) - return summarize(session, agent?.status === 'running') + const projections = listProjectionsFor(ctx, session.id, session) + return { + ...summarize(session, agent?.status === 'running'), + ...projections === undefined ? {} : { projections }, + } }) const attached = new Set(items.map(item => item.sessionId)) const persistence = ctx.get('sessionPersistence') if (persistence !== undefined) { const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta)))) + items.push(...await Promise.all(cold.map(async (meta) => { + // Cold rows read the persisted projection cache only — never a + // log load; a session without a cache row simply has no column. + const projections = listProjectionsFor(ctx, meta.id, undefined) + return { + ...await summarizeCold(persistence, meta), + ...projections === undefined ? {} : { projections }, + } + }))) } items.sort((a, b) => b.updatedAt - a.updatedAt) return ok(request, { items }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c674337bc..a02267a6bf 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -37,6 +38,15 @@ export const sessionEventSchema = z.object({ surfaceOp: z.unknown().optional(), }) as unknown as z.ZodType +/** + * Projection-values passthrough (same posture as + * {@link sessionProjectionsBlockSchema}): each value already passed its + * unit's own schema on the host side; deep-validating here would import + * every domain's schema into the carrier. + */ +const projectionValuesSchema = + z.record(z.string(), z.unknown()) as unknown as z.ZodType> + /** SessionSummary row of session.list. */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, @@ -45,6 +55,7 @@ export const sessionSummarySchema = z.object({ blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional(), + projections: projectionValuesSchema.optional(), }) satisfies z.ZodType> /** session.list request payload (cursor is a reserved seat, unimplemented in v1). */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 8c7be54440..a6d5c1517c 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -143,6 +143,17 @@ export interface SessionSummary { parentSessionId?: SessionId /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Whole current value per projection key, with zero log loads: attached + * sessions read the registry's live watermark cut; cold sessions read the + * persisted projection cache's stored rows — as stale as that session's + * last durable checkpoint, never wrong, superseded by the history tail + * baseline the moment the session is opened. Absent when no value is + * available (no registry, no cache row for a cold session, or a fail-soft + * cache read miss); a listing client treats absence as "no title yet", + * exactly like a blank session. + */ + projections?: Partial } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index e8999bdeda..caa957d05d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -125,6 +125,77 @@ describe('session.history projections block', () => { }) }) +describe('session.list projections column', () => { + it('serves attached rows from the live registry cut', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row?.projections?.['test/last-user']).toEqual({ text: 'm0' }) + }) + + it('omits the column entirely when no registry is mounted', async () => { + const { ctx, session } = await harness(false) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('serves cold rows from the persisted projection cache with zero log loads', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-listing') + const load = () => { throw new Error('list must not load event logs') } + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load, + inspect: load, + readFrom: load, + } as never) + ctx.provide('sessionProjectionCache', { + cachedValues: (id: unknown) => (id === coldId ? { 'test/last-user': { text: 'cached' } } : {}), + } as never) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === coldId) + expect(row?.running).toBe(false) + expect(row?.projections?.['test/last-user']).toEqual({ text: 'cached' }) + }) + + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-uncached') + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + } as never) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === coldId) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('a throwing column read degrades that row, never the listing', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register({ + ...lastUserUnit(), + view: () => { throw new Error('unit exploded') }, + }) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) +}) + describe('session/projection push frame', () => { /** Drain frames until `count` session/projection frames arrived. */ async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index bf65db029d..fbe8e77719 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../session-projection/session-projection-cache" + }, { "path": "../../skill/skill" }, diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index c9336dd848..abb39ad1de 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -19,7 +19,7 @@ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' import type { CheckpointRecord } from './spec.ts' @@ -98,6 +98,20 @@ export class SessionProjectionCache extends Service { return this.requireTable().get(id)?.rows ?? {} } + /** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), as stale as the last durable + * checkpoint but never wrong. Synchronous — a listing over every stored + * session touches no log. Fresher paths (the history tail baseline, + * {@link coldSnapshot}) supersede these values whenever a session is + * actually opened. + * @param id - the session whose cached values are viewed. + * @returns whole values per key with a usable row; empty when none stored. + */ + cachedValues(id: SessionId): Partial { + return this.ctx.sessionProjections.viewCheckpoint(this.checkpointOf(id)) + } + /** * Durably checkpoint one live session NOW (both mandatory points call * this; tests and carriers may too). The registry cut is snapshotted at diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index c2974b565d..2f952166cc 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -280,6 +280,27 @@ export class SessionProjectionRegistry extends Service { return floor === undefined ? undefined : Math.max(floor - 1, 0) } + /** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `stateVersion` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ + viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial { + const values: Record = {} + for (const registration of this.registrations.values()) { + const def = registration.def + const row = checkpoint[def.key] + if (row === undefined || row.stateVersion !== def.stateVersion) continue + values[def.key] = def.schema.parse(def.view(row.state)) + } + return values as Partial + } + /** * Cold read: fold every registered unit over a stored log suffix, seeding * each from its checkpoint row when usable — the one read recipe (cached diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 3c069f91d3..fc167a1625 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -267,6 +267,19 @@ describe('SessionProjectionRegistry drive', () => { expect(current.values['test/count']).toBe(5) }) + it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const values = ctx.sessionProjections.viewCheckpoint({ + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } }, + 'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent + }) + expect(values['test/marks']).toEqual({ marks: ['stored'] }) + expect('test/count' in values).toBe(false) + expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({}) + }) + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e189366cb..408a990c35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2685,6 +2685,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill From a6e35c27d48817e7da17c846ee6a57d27cf86147 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:43:05 +0800 Subject: [PATCH 35/61] fix(session-projection): checkpoint hands out detached state clones, never live cell references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watermark cache is the registry's authoritative mutable state; a checkpoint consumer holding the live reference could corrupt every subsequent snapshot and frame through it. structuredClone at the read face (total, by the unit plain-JSON contract) pins the boundary; a mutation test proves the cache is unreachable through handed-out rows. restore and viewCheckpoint only touch caller-owned rows — no other leak path. --- .../session-projection/src/index.ts | 10 ++++++---- .../session-projection/tests/registry.spec.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 2f952166cc..5b7a3c1f4c 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -233,9 +233,11 @@ export class SessionProjectionRegistry extends Service { * log). This is the write side of the persisted projection cache: the * returned rows are the `(key → {stateVersion, observedSeq, state})` part * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. States are the units' live references — plain JSON by the unit - * contract, treated as immutable; a durable writer snapshots them at its - * own boundary. + * rows. Every `state` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. * @returns one row per registered key; empty when no unit is registered. */ @@ -246,7 +248,7 @@ export class SessionProjectionRegistry extends Service { rows[registration.def.key] = { stateVersion: registration.def.stateVersion, observedSeq: cell.observedSeq, - state: cell.state, + state: structuredClone(cell.state), } } return rows diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index fc167a1625..17ee5aaf84 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -182,6 +182,19 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) }) + it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { + const { ctx, session } = await harness() + ctx.sessionProjections.register(marksUnit()) + mark(session, ['a']) + const rows = ctx.sessionProjections.checkpoint(session) + // Hostile (or merely careless) consumer mutates the handed-out state. + ;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED') + // The registry's authoritative cell is untouched: snapshot and a fresh + // checkpoint both still serve the committed value. + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] }) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] }) + }) + it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { const { ctx } = await harness() expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered From c0eec9d9e20bd00d254c24ab0113c520861f8969 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:59:51 +0800 Subject: [PATCH 36/61] docs: bilingual counterparts for the projection-cache README and the touched pair records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base's bilingual gate now covers this branch's new README: add the Chinese counterpart of the session-projection-cache package README, the cache row on the group README's Chinese side, and re-record the touched pairs (group, cache, session-persistence — whose English side gained the readFrom rows earlier on this branch). verify-translation-pairing: 541 pairs consistent. --- packages/session-projection/README.i18n.yaml | 4 +- packages/session-projection/README.zh.md | 1 + .../session-projection-cache/README.i18n.yaml | 6 ++ .../session-projection-cache/README.md | 2 + .../session-projection-cache/README.zh.md | 62 +++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/session-projection/session-projection-cache/README.i18n.yaml create mode 100644 packages/session-projection/session-projection-cache/README.zh.md diff --git a/packages/session-projection/README.i18n.yaml b/packages/session-projection/README.i18n.yaml index a850031e0b..eb131c5d8b 100644 --- a/packages/session-projection/README.i18n.yaml +++ b/packages/session-projection/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 packages/session-projection/README.md -README.md: 81c67d56e136ba4853e86d889b485d4df80ac1fe -README.zh.md: 72e23b78a48a989f355f9be3d34d81a440ca1d04 +README.md: ae80a905705d205adb4a1ee66c72fa28d0d8b6d6 +README.zh.md: 97e25dd16caeeb444f5f5309eed3341d422fa1b1 diff --git a/packages/session-projection/README.zh.md b/packages/session-projection/README.zh.md index 72e23b78a4..97e25dd16c 100644 --- a/packages/session-projection/README.zh.md +++ b/packages/session-projection/README.zh.md @@ -7,3 +7,4 @@ | 包 | ctx 键 | 职责 | |---|---|---| | [`session-projection`](session-projection/README.md) | `sessionProjections` | 接口包(package):merge-extensible 的 `SessionProjectionMap` 类型表、`ProjectionDefinition` 单元契约,以及供载体同步读取的正向驱动注册表 | +| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | 持久投影缓存:基于域数据形态的按会话单元 checkpoint 持久化、带 turn/end + detach 两个必写点的节流后写,以及冷读阶梯(缓存行 + 持久化尾部重放) | diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml new file mode 100644 index 0000000000..57df7abd74 --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md +README.md: 81d6094c19538f5559becbff51b00a7bfacb4206 +README.zh.md: f403ac1a897f5bac840b43dab6fc0f98bf7b7a46 diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 8bb8efe4cd..81d6094c19 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-projection-cache +English | [中文](README.zh.md) + The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md new file mode 100644 index 0000000000..f403ac1a89 --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-session-projection-cache + +[English](README.md) | 中文 + +持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 + +一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: + +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 +- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 +- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 + +## 写策略 + +两个必写点,其间节流: + +| 触发 | 性质 | +|---|---| +| `turn/end` | 必写——冷读要的正是轮次终值。 | +| 会话销毁(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 | +| 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 | +| 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 | + +两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。 + +## 冷读(`coldSnapshot(id, signal?)`) + +读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 + +`write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。 + +## 组合 + +```yaml +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 +``` + +注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。 + +## 模型体验 + +### 模型看到什么 + +什么都看不到。缓存是 host 侧读模型加速器;没有提示词、schema 或工具表面。 + +### Token 影响 + +零。 + +### KV 缓存影响 + +无——不改变任何请求内容。 + +## 已知局限与延后工作 + +- **没有淘汰或保留面**——记录按会话累积;清理存储的检查点是带外维护,与会话持久化本身同一立场。 +- **间隔节流按会话粗粒度**——计时器在一次干净写入后的首个脏事件时武装;持续的低于阈值的涓流每个间隔写一次,不是滑动窗口。 +- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。 From 9ae3e1a9ad4e220d04516651bdd3361cccb63148 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:11:31 +0800 Subject: [PATCH 37/61] docs: regenerate catalogs and graphs for the projection-cache seam; classify its types gen-cordis-catalog/api, config and persistence catalogs, and doc graphs regenerated over the new sessionProjectionCache service and the registry's checkpoint faces. Classifications: ProjectionCheckpoint joins the type-link exemptions (owned by the projection package source), Partial joins the foundation names, the cache service gets its capability-seam role row, and the package takes the one-sentence Model Experience contract (host-side read-model accelerator, no model surface). --- docs/capability-seams.md | 5 + docs/config-catalog.md | 21 +++ docs/cordis-catalog/services.md | 147 +++++++++++++++++- docs/event-producer-consumer.md | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 50 ++++++ .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 12 +- .../session-projection-cache/README.zh.md | 12 +- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 8 + .../verify-package-readme-model-experience.ts | 1 + 11 files changed, 242 insertions(+), 24 deletions(-) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 87efb87c87..8f75b634b5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -77,6 +77,8 @@ flowchart LR pkg_session_projection["session-projection"] svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_host_apiproxy["host-apiproxy"] + pkg_session_projection_cache["session-projection-cache"] + svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] @@ -184,6 +186,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections + pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences @@ -261,6 +264,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash + svc_sessionProjectionCache --> pkg_host_apiproxy svc_sessionProjections --> pkg_host_apiproxy svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo @@ -336,6 +340,7 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 676054c215..b5e320f7b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1048,6 +1048,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-projection-cache` + +Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} +``` + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts) + ## `@deepseek-ai/dsh-session-query-sqlite` Requires: `sessions` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7bb03af778..a14a365da2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1063,6 +1063,25 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +/** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ +abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. @@ -1087,6 +1106,60 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +## `ctx.sessionProjectionCache` — `SessionProjectionCache` + +The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. + +```ts cordis-catalog +/** + * The stored checkpoint rows for one session, or an empty checkpoint when + * none is stored. Synchronous from the domain's in-memory state. + * @param id - the session whose cached rows are read. + * @returns the persisted `key → row` checkpoint (possibly empty). + */ +checkpointOf(id: SessionId): ProjectionCheckpoint + +/** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), as stale as the last durable + * checkpoint but never wrong. Synchronous — a listing over every stored + * session touches no log. Fresher paths (the history tail baseline, + * {@link coldSnapshot}) supersede these values whenever a session is + * actually opened. + * @param id - the session whose cached values are viewed. + * @returns whole values per key with a usable row; empty when none stored. + */ +cachedValues(id: SessionId): Partial + +/** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ +async write(session: Session): Promise + +/** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ +async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise +``` + +Types: [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) + ## `ctx.sessionProjections` — `SessionProjectionRegistry` `ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -1119,11 +1192,81 @@ onChanged(listener: ProjectionChangeListener): () => void * @returns the snapshot; `values` is empty when no unit is registered. */ snapshot(session: Session): ProjectionSnapshot + +/** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {stateVersion, observedSeq, state})` part + * of the durable `(sessionId, key, stateVersion, observedSeq, state)` + * rows. Every `state` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ +checkpoint(session: Session): ProjectionCheckpoint + +/** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `stateVersion` matches the live unit; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ +restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined + +/** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `stateVersion` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ +viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial + +/** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `stateVersion` matches the live unit, it does not predate `baseSeq` + * (`observedSeq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ +restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:136`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..55210a40b3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 30ff0534ca..33179837b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -524,6 +524,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, + { + signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', + }, { signature: 'abstract list(signal?: AbortSignal): Promise', jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */', @@ -534,6 +538,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionProjectionCache', + summary: 'The persisted projection cache service.', + methods: [ + { + signature: 'checkpointOf(id: SessionId): ProjectionCheckpoint', + jsDoc: '/**\n * The stored checkpoint rows for one session, or an empty checkpoint when\n * none is stored. Synchronous from the domain\'s in-memory state.\n * @param id - the session whose cached rows are read.\n * @returns the persisted `key → row` checkpoint (possibly empty).\n */', + }, + { + signature: 'cachedValues(id: SessionId): Partial', + jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), as stale as the last durable\n * checkpoint but never wrong. Synchronous — a listing over every stored\n * session touches no log. Fresher paths (the history tail baseline,\n * {@link coldSnapshot}) supersede these values whenever a session is\n * actually opened.\n * @param id - the session whose cached values are viewed.\n * @returns whole values per key with a usable row; empty when none stored.\n */', + }, + { + signature: 'async write(session: Session): Promise', + jsDoc: '/**\n * Durably checkpoint one live session NOW (both mandatory points call\n * this; tests and carriers may too). The registry cut is snapshotted at\n * this boundary (states are live references), then the whole record is\n * replaced. NOT fail-soft — callers on the fail-soft paths contain it.\n * @param session - the live session to checkpoint.\n * @returns resolution after durability and event emission.\n */', + }, + { + signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Cold-read one persisted session\'s projections with zero full-log load:\n * cached rows + a persistence `readFrom` tail from the registry\'s restore\n * floor, refolded by the registry and written back (fail-soft) so the next\n * cold read starts closer. A cache row invalidated by a shrunk log\n * (crash-repair truncation) triggers one full re-read from seq 0 — the\n * ladder\'s slow rung, still no crash. Rejects when the session has no\n * persisted log (`not found` from the persistence seam).\n * @param id - the persisted session to read.\n * @param signal - optional cancellation for the persistence reads.\n * @returns the snapshot cut at the stored log end.\n */', + }, + ], + }, { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', @@ -550,6 +576,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'snapshot(session: Session): ProjectionSnapshot', jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */', }, + { + signature: 'checkpoint(session: Session): ProjectionCheckpoint', + jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', + }, + { + signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', + jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', + }, + { + signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', + jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', + }, + { + signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', + }, ], }, { @@ -1855,6 +1897,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', }, + { + name: 'ProjectionCheckpoint', + declaration: 'export type ProjectionCheckpoint = Record;', + }, + { + name: 'ProjectionCheckpointRow', + declaration: 'export interface ProjectionCheckpointRow {\n stateVersion: number;\n observedSeq: number;\n state: unknown;\n}', + }, { name: 'ProjectionDefinition', declaration: 'export interface ProjectionDefinition {\n key: K;\n schema: ZodType;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}', diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 57df7abd74..43e9dcc641 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/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 packages/session-projection/session-projection-cache/README.md -README.md: 81d6094c19538f5559becbff51b00a7bfacb4206 -README.zh.md: f403ac1a897f5bac840b43dab6fc0f98bf7b7a46 +README.md: 662504aad83824546cd79977f3d87dbd06281038 +README.zh.md: 963a1dc07388e2d881902a942872cf94bf734d1f diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 81d6094c19..662504aad8 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -43,17 +43,11 @@ Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. ## Model Experience -### What the model sees +None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result. -Nothing. The cache is a host read-model accelerator; no prompt, schema, or tool surface. +#### KV Cache effect -### Token effect - -Zero. - -### KV Cache effect - -None — no request content changes. +None; the cache never assembles or sends provider requests. ## Known Limitations and Deferred Work diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index f403ac1a89..963a1dc073 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -43,17 +43,11 @@ ## 模型体验 -### 模型看到什么 +无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 -什么都看不到。缓存是 host 侧读模型加速器;没有提示词、schema 或工具表面。 +#### KV 缓存影响 -### Token 影响 - -零。 - -### KV 缓存影响 - -无——不改变任何请求内容。 +无;缓存从不组装或发送提供方请求。 ## 已知局限与延后工作 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f35c5b4547..43f48019c5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -209,6 +209,7 @@ const FOUNDATION_TYPE_NAMES = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Partial', 'Pick', 'Promise', 'Readonly', @@ -237,6 +238,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts', ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts', ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts', + ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts', CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 67dbee1e77..075ca3a69e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -244,6 +244,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-todo', 'session-title', 'host-apiproxy'], note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', }, + { + key: 'sessionProjectionCache', + pkg: 'session-projection-cache', + title: 'Persisted projection cache', + mode: 'core', + consumers: ['host-apiproxy'], + note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', + }, { key: 'tui', pkg: 'tui', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 55108269a3..b5ada214a3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -89,6 +89,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, + 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, From 54c893d7af3c6a1e8c54a9d892d6a9533e0920b3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:07:23 +0800 Subject: [PATCH 38/61] =?UTF-8?q?docs:=20RFC=20=E2=80=94=20storage=20root?= =?UTF-8?q?=20placement=20and=20derived-medium=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projection cache surfaced two substrate gaps, both properties of the domain-KV stack it landed on. (1) The json backend's root is relative and joined per-open against process.cwd(): sessions are patched global (~/.dsh/sessions) but workspace.json/session_projcache.json land under /.storages, splitting derived media from their source of truth. Proposal: patch storage-json.root to ~/.dsh/storages beside the session root (profile key storageRoot, mirroring persistenceRoot) and resolve-once at backend construction, adopting the JSONL backend's recorded rationale. (2) A damaged cache medium (truncated / version-bumped / schema-drifted) bricks fail-loud boot even though its content is fully rebuildable from session logs. Proposal: DomainSpec grows recovery: 'reject' | 'reset'; the facility, on exactly the damage-class errors, destroys a declared-reset medium once and reopens empty — workspace stays authoritative and loud. Bilingual pair recorded. --- ...root-and-derived-medium-recovery.i18n.yaml | 6 ++ ...torage-root-and-derived-medium-recovery.md | 57 +++++++++++++++++++ ...age-root-and-derived-medium-recovery.zh.md | 57 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml new file mode 100644 index 0000000000..af41b392bd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md new file mode 100644 index 0000000000..06fa98b10d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -0,0 +1,57 @@ +# Agent Note: Storage root placement and derived-medium recovery + +Status: proposed + +English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md) + +## Problem + +The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack. + +**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. + +**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. + +## Proposal + +Two independent changes, one per gap. + +### One global storage root, resolved once + +- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today. +- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path. +- Pre-release stance applies: no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once. + +### Declared derived media: reset instead of reject + +- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default. +- `KvFacet` gains one primitive: `destroy(descriptor): Promise` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy. +- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop. +- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped. + +## Alternatives considered + +**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory. + +**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single. + +**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures. + +**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way. + +**Rename the damaged medium aside (`.json.corrupt-`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec. + +**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner. + +## Acceptance criteria + +- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction. +- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed. +- The same damage to `workspace.json` still fails boot loudly. +- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends. + +## Risks + +- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open. +- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content. +- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md new file mode 100644 index 0000000000..b7bd18ffdb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -0,0 +1,57 @@ +# Agent Note:存储根目录落点与派生介质恢复 + +Status: proposed + +[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文 + +## Problem + +持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。 + +**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 + +**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 + +## Proposal + +两个独立改动,一个缺口一个。 + +### 全局唯一存储根,构造时 resolve 一次 + +- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。 +- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。 +- 适用 pre-release 立场:不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。 + +### 声明派生介质:损坏时重置而非拒绝 + +- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。 +- `KvFacet` 增加一个原语:`destroy(descriptor): Promise`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。 +- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。 +- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。 + +## Alternatives considered + +**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。 + +**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 + +**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。 + +**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。 + +**把损坏介质改名旁置(`.json.corrupt-`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。 + +**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。 + +## Acceptance criteria + +- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。 +- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。 +- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。 +- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。 + +## Risks + +- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。 +- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。 +- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。 From 1ef7c9473ce34c03b600659ca7320a8f87606a29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:44:19 +0800 Subject: [PATCH 39/61] fix(session-projection-cache): coldSnapshot honors not-found with zero registered units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): with no projection definitions registered, restoreFloor() is undefined and the fast path returned a successful empty snapshot without touching persistence — a nonexistent session 'succeeded', violating the documented not-found contract in that supported topology. The no-unit branch now probes readFrom(id, 0): an absent log rejects with the seam's not-found, a present one dates the empty cut at its stored end. --- .../session-projection-cache/src/index.ts | 8 ++++++- .../tests/cache.spec.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index abb39ad1de..92515015f8 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -141,8 +141,14 @@ export class SessionProjectionCache extends Service { async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { const cached = this.checkpointOf(id) const floor = this.ctx.sessionProjections.restoreFloor(cached) - if (floor === undefined) return { asOfSeq: -1, values: {} } const persistence = this.ctx.sessionPersistence + if (floor === undefined) { + // No unit registered: nothing to fold, but the not-found contract must + // hold in this topology too — the probe read rejects for an absent log + // and dates the empty cut for a present one. + const probe = await persistence.readFrom(id, 0, signal) + return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} } + } let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } const tail = await persistence.readFrom(id, floor, signal) try { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index defeddfd32..33d82cea24 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -252,4 +252,26 @@ describe('SessionProjectionCache cold read', () => { const { cache } = await harness() await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') }) + + it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => { + // Same composition minus any registered unit: restoreFloor is undefined, + // yet coldSnapshot must still reject for an absent log (probe read) and + // serve an empty cut at the stored end for a present one. + const pool = new MemoryMediaPool() + const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2 + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('sessionPersistence', fakePersistence(logs) as never) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare'))) + .resolves.toEqual({ asOfSeq: 2, values: {} }) + }) }) From 27198d309198a1fed552a5357474c431e827c56c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:45 +0800 Subject: [PATCH 40/61] fix(session-projection-cache): bind records to the log lifecycle; flush before checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): rows carried only version/watermark/state, so a recreated session id, or a persistence store replaced under a surviving cache, could pass every watermark check and seed state folded from an unrelated log; a checkpoint racing ahead of an eager log flush could likewise expose values no stored log contains. Records now store the header identity (createdAt, cwd) they were folded from — reads validate it against the live header (listing) or the tail's stored header (cold read) and discard unrelated records whole (domain version 2 discards v1 media by the pre-release stance). A live checkpoint additionally flushes the session's buffered events durably before the cache row lands: the cache can trail the log, never lead it. cachedValues is reshaped into cachedSnapshot(meta): the identity witness plus the {asOfSeq, values} cut the list carrier serves. --- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 6 + .../session-projection-cache/README.zh.md | 6 + .../session-projection-cache/src/index.ts | 103 ++++++++++++------ .../session-projection-cache/src/spec.ts | 29 ++++- .../tests/cache.spec.ts | 55 ++++++++-- 6 files changed, 158 insertions(+), 45 deletions(-) diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 43e9dcc641..2214de288d 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/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 packages/session-projection/session-projection-cache/README.md -README.md: 662504aad83824546cd79977f3d87dbd06281038 -README.zh.md: 963a1dc07388e2d881902a942872cf94bf734d1f +README.md: a10bb858159e9581c815d2532a893208c45e788a +README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 662504aad8..a10bb85815 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -9,6 +9,8 @@ A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. - **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. +- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. +- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. ## Write policy @@ -23,6 +25,10 @@ Two mandatory points, throttled in between: Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml. +## Listing read (`cachedSnapshot(meta)`) + +The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column. + ## Cold read (`coldSnapshot(id, signal?)`) The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`. diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index 963a1dc073..191efbc2c7 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -9,6 +9,8 @@ - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 - **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 +- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 +- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。 ## 写策略 @@ -23,6 +25,10 @@ 两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。 +## 列表读(`cachedSnapshot(meta)`) + +零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值仓时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 + ## 冷读(`coldSnapshot(id, signal?)`) 读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index 92515015f8..7e217e314e 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -15,17 +15,17 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' -import type { CheckpointRecord } from './spec.ts' +import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' -export { checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' -export type { CheckpointRecord } from './spec.ts' +export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' +export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' declare module 'cordis' { interface Context { @@ -89,27 +89,44 @@ export class SessionProjectionCache extends Service { } /** - * The stored checkpoint rows for one session, or an empty checkpoint when - * none is stored. Synchronous from the domain's in-memory state. - * @param id - the session whose cached rows are read. - * @returns the persisted `key → row` checkpoint (possibly empty). + * The stored record for one session, accepted only when its bound log + * identity matches `expected`. A session id names a slot, not a lifecycle: + * a recreated id or a persistence store swapped under a surviving cache + * must not let an old record seed state folded from an unrelated log. + * Synchronous from the domain's in-memory state. + * @param id - the session whose record is read. + * @param expected - the log identity the caller holds (live or stored header). + * @returns the identity-matching record, or `undefined` (absent or unrelated). */ - checkpointOf(id: SessionId): ProjectionCheckpoint { - return this.requireTable().get(id)?.rows ?? {} + private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined { + const record = this.requireTable().get(id) + if (record === undefined) return undefined + return identityMatches(record.identity, expected) ? record : undefined } /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), as stale as the last durable - * checkpoint but never wrong. Synchronous — a listing over every stored - * session touches no log. Fresher paths (the history tail baseline, - * {@link coldSnapshot}) supersede these values whenever a session is - * actually opened. - * @param id - the session whose cached values are viewed. - * @returns whole values per key with a usable row; empty when none stored. + * rows (version-matching keys only), each cut carried with its watermark + * so a client value store can seed under its higher-seq-wins rule — as + * stale as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline, {@link coldSnapshot}) supersede these + * values whenever a session is actually opened. + * @param meta - the listed session's header (identity witness; no log read). + * @returns the cut (`asOfSeq` = lowest served-row watermark), or + * `undefined` when no usable row exists for this lifecycle. */ - cachedValues(id: SessionId): Partial { - return this.ctx.sessionProjections.viewCheckpoint(this.checkpointOf(id)) + cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined { + const record = this.recordFor(meta.id, identityOf(meta)) + if (record === undefined) return undefined + const values = this.ctx.sessionProjections.viewCheckpoint(record.rows) + const keys = Object.keys(values) + if (keys.length === 0) return undefined + // The block carries ONE cut: the lowest served watermark is the seq every + // value is at least current as of (under-claiming is safe under + // higher-seq-wins; over-claiming would let a stale value outrank pushes). + const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq)) + return { asOfSeq, values } } /** @@ -123,7 +140,15 @@ export class SessionProjectionCache extends Service { async write(session: Session): Promise { const rows = this.ctx.sessionProjections.checkpoint(session) this.markClean(session) - await this.put(session.id, rows) + // Durability barrier: the checkpoint cut was taken above, so flushing + // AFTER it guarantees every event inside the cut is durably logged + // before the cache row lands — a crash can leave the cache behind the + // log (longer tail replay) but never ahead of it (phantom values folded + // from events no stored log contains). At detach the store entry is + // already gone; persistence's own retirement drain covers that path and + // any residual overreach is caught by the cold read's anchored floor. + if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session) + await this.put(session.id, identityOf(session.header), rows) } /** @@ -139,7 +164,8 @@ export class SessionProjectionCache extends Service { * @returns the snapshot cut at the stored log end. */ async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { - const cached = this.checkpointOf(id) + const record = this.requireTable().get(id) + const cached = record?.rows ?? {} const floor = this.ctx.sessionProjections.restoreFloor(cached) const persistence = this.ctx.sessionPersistence if (floor === undefined) { @@ -151,16 +177,21 @@ export class SessionProjectionCache extends Service { } let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } const tail = await persistence.readFrom(id, floor, signal) + // The tail's stored header is the identity witness: a record bound to a + // different lifecycle (recreated id, swapped store) is discarded whole + // before any of its rows can seed a fold. + const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta)) try { + if (!related) throw new Error('unrelated log identity') restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) } catch { - // The one recoverable restore failure: a row overreaching the stored - // log end (or predating the floor), detected by the registry. Both + // The recoverable restore failures: an unrelated record, or a row + // overreaching the stored log end (or predating the floor). All // resolve identically — discard the cache and refold the full log. - const whole = await persistence.readFrom(id, 0, signal) + const whole = floor === 0 && related ? tail : await persistence.readFrom(id, 0, signal) restored = this.ctx.sessionProjections.restore({}, whole.events, 0) } - await this.putSoft(id, restored.checkpoint, 'cold-read write-back') + await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') return restored.snapshot } @@ -229,19 +260,19 @@ export class SessionProjectionCache extends Service { } } - /** Replace one session's stored record with a detached snapshot of `rows`. */ - private async put(id: SessionId, rows: ProjectionCheckpoint): Promise { + /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */ + private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise { const detached = snapshotJsonValue(rows) if (detached === undefined) { throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)') } - await this.requireTable().put(id, { rows: detached as CheckpointRecord['rows'] }) + await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] }) } /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */ - private async putSoft(id: SessionId, rows: ProjectionCheckpoint, what: string): Promise { + private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise { try { - await this.put(id, rows) + await this.put(id, identity, rows) } catch (error) { this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`) } @@ -254,4 +285,14 @@ export class SessionProjectionCache extends Service { } } +/** Project a header onto the identity fields a record is bound to. */ +function identityOf(header: SessionHeader): CheckpointIdentity { + return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } } +} + +/** Whether a stored record's bound identity names the caller's lifecycle. */ +function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean { + return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd +} + export default SessionProjectionCache diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts index 1cc4931032..72796b6c4b 100644 --- a/packages/session-projection/session-projection-cache/src/spec.ts +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -28,11 +28,30 @@ export const checkpointRow = z.object({ }) /** - * One session's stored record: its checkpoint rows keyed by projection key. - * The whole record is replaced on every write (whole-value discipline — the - * registry checkpoint is always the complete per-session cut). + * The stored-log identity a record is bound to: the immutable header fields + * that distinguish one session lifecycle from another under the same id. A + * session id names a slot, not a lifecycle — a deleted-then-recreated id, or + * a persistence root swapped under a surviving cache, would otherwise let an + * old row pass every watermark check and seed state folded from an unrelated + * log. Reads validate this against the live header (listing) or the stored + * header (cold read) before accepting any row. + */ +export const checkpointIdentity = z.object({ + createdAt: z.number().int().nonnegative(), + cwd: z.string().optional(), +}) + +/** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */ +export type CheckpointIdentity = z.infer + +/** + * One session's stored record: the log identity it was folded from plus its + * checkpoint rows keyed by projection key. The whole record is replaced on + * every write (whole-value discipline — the registry checkpoint is always + * the complete per-session cut). */ export const checkpointRecord = z.object({ + identity: checkpointIdentity, rows: z.record(z.string(), checkpointRow), }) @@ -42,10 +61,10 @@ export type CheckpointRecord = z.infer /** * The session-projcache domain spec. Version bumps discard the whole medium * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). + * never a wrong value). v2 added the record's log-identity binding. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', - version: 1, + version: 2, tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 33d82cea24..94406b1803 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -44,7 +44,7 @@ const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', M stateVersion, }) -/** A persistence double serving readFrom over a fixed per-id stored log. */ +/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */ function fakePersistence(logs: Map) { const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { const events = logs.get(String(id)) @@ -57,6 +57,9 @@ function fakePersistence(logs: Map) { return { readFrom } } +/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */ +const headerOf = (id: SessionId, createdAt = 0) => ({ version: 0, id, createdAt }) + interface HarnessOptions { pool?: MemoryMediaPool config?: { writeEveryEvents: number; writeIntervalMs: number } @@ -91,11 +94,18 @@ const mark = (session: Session, marks: string[]): SessionEvent => const endTurn = (session: Session): SessionEvent => session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) +/** The stored medium record for one session id (undefined = never written). */ +function storedRecord(pool: MemoryMediaPool, id: Session['id']) { + return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as + { + identity: { createdAt: number; cwd?: string } + rows: Record + } | undefined +} + /** The stored medium rows for one session id (undefined = never written). */ function storedRows(pool: MemoryMediaPool, id: Session['id']) { - const record = pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as - { rows: Record } | undefined - return record?.rows + return storedRecord(pool, id)?.rows } /** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */ @@ -187,10 +197,15 @@ describe('SessionProjectionCache cold read', () => { } /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */ - function seedRow(pool: MemoryMediaPool, id: string, row: { stateVersion: number; observedSeq: number; state: unknown }): void { - pool.versions.set('session_projcache', 1) + function seedRow( + pool: MemoryMediaPool, + id: string, + row: { stateVersion: number; observedSeq: number; state: unknown }, + identity: { createdAt: number; cwd?: string } = { createdAt: 0 }, + ): void { + pool.versions.set('session_projcache', 2) pool.media.set('session_projcache', { - tables: new Map([['sessions', new Map([[id, { rows: { 'cache-test/marks': row } }]])]]), + tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]), global: null, }) } @@ -253,6 +268,32 @@ describe('SessionProjectionCache cold read', () => { await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') }) + it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0 + // A checkpoint from a PRIOR lifecycle of the same id (different createdAt): + // its rows pass every watermark check, but the identity does not match. + seedRow(pool, 'reborn', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 }) + const { cache, pool: samePool } = await harness({ pool, logs }) + const snapshot = await cache.coldSnapshot(SessionId('reborn')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] }) + // The write-back rebinds the record to the actual log's identity. + expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 }) + }) + + it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) + const { cache } = await harness({ pool }) + const id = SessionId('listed') + // Matching header: values plus the watermark the client seeds under. + expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } }) + // A recreated id (different createdAt): the record is unrelated — no block. + expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined() + // Unknown id: no block. + expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined() + }) + it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => { // Same composition minus any registered unit: restoreFloor is undefined, // yet coldSnapshot must still reject for an absent log (probe read) and From c46419cf5c9348d7a64f794d393116f1c3fd26f7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:10:04 +0800 Subject: [PATCH 41/61] fix(apiproxy,client): the list projection column becomes a seedable watermarked block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): the column carried bare values (no seq), so the client could not seed its value store without risking a stale list block outranking newer push frames — and nothing consumed the column at all, leaving cold titles absent after a restart. SessionSummary.projections is now the same SessionProjectionsBlock as the history tail (values + asOfSeq; attached rows cut the live registry, cold rows serve the cache's identity-checked cachedSnapshot whose asOfSeq is the lowest served-row watermark). SessionManager.refreshList seeds each row's block into the per-session projection store via per-key apply — partial-baseline semantics: an absent key never clears, and higher-seq-wins keeps stale list blocks beneath push frames and tail baselines — so cold titles surface in the sidebar without opening a session. --- .../runtime/src/client/sessions/manager.ts | 13 ++++++++ packages/client/runtime/tests/manager.spec.ts | 22 ++++++++++++++ packages/host/apiproxy/src/api-proxy.ts | 30 ++++++++++--------- .../host/apiproxy/src/api/sessions.schema.ts | 20 ++++--------- packages/host/apiproxy/src/api/sessions.ts | 19 ++++++------ .../tests/api-proxy-projections.spec.ts | 13 +++++--- 6 files changed, 75 insertions(+), 42 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 66a0d00dd9..1e88e9f8ba 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -212,6 +212,19 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } + // Seed each row's projection baseline into the per-session value + // store (cold titles surface without opening the session). Per-key + // apply, not seed(): the list block is a partial baseline — the + // cold cache serves only version-matching keys — so an absent key + // must not clear; higher-seq-wins still keeps a stale list block + // from overwriting a newer push frame or tail baseline. + for (const s of result.value.items) { + const block = s.projections + if (block === undefined) continue + const store = this.projectionStore(s.sessionId) + const values = block.values as Record + for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + } } else { this.listState = 'error' this.listError = result.error diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2923cd0d3c..85b9ed6b75 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -160,6 +160,28 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) + it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + // A push frame landed before the list (S2's title is newer than the block's cut). + manager.handleMuxEnvelope({ + rpcId: 'push-newer' as never, + payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never, + }) + api.onList = () => Promise.resolve(ok({ + items: [ + { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + ] as never[], + })) + await manager.refreshList() + const items = manager.getListSnapshot().items + // Cold row: title surfaces straight from the list block — no open, no history. + expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). + expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') + }) + it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 486a528a78..8526052a8b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -29,7 +29,7 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. @@ -300,21 +300,23 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u } /** - * The projection column of one session.list row, fail-soft: attached + * The projection baseline of one session.list row, fail-soft: attached * sessions cut the registry's live watermark cache; cold sessions view the - * persisted projection cache's stored rows (zero log loads either way — the - * listing use case the cache exists for). Any failure — and an empty value - * set — yields an absent column: a listing without projections is degraded, - * never broken. + * persisted projection cache's identity-checked stored rows (zero log loads + * either way — the listing use case the cache exists for). The block shape + * (values + asOfSeq) matches the history tail's, so a client seeds its + * value store under the same higher-seq-wins rule. Any failure — and an + * empty value set — yields an absent block: a listing without projections + * is degraded, never broken. */ -function listProjectionsFor(ctx: Context, id: SessionId, session: Session | undefined): Partial | undefined { +function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined { try { - const values = session !== undefined - ? ctx.get('sessionProjections')?.snapshot(session).values - : ctx.get('sessionProjectionCache')?.cachedValues(id) - return values !== undefined && Object.keys(values).length > 0 ? values : undefined + const block = session !== undefined + ? ctx.get('sessionProjections')?.snapshot(session) + : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta) + return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined } catch (error) { - ctx.logger.warn(`session.list: projection column for "${id}" failed (serving the row without it): ${String(error)}`) + ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`) return undefined } } @@ -676,7 +678,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async list(request) { const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) - const projections = listProjectionsFor(ctx, session.id, session) + const projections = listProjectionsFor(ctx, session.header, session) return { ...summarize(session, agent?.status === 'running'), ...projections === undefined ? {} : { projections }, @@ -689,7 +691,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro items.push(...await Promise.all(cold.map(async (meta) => { // Cold rows read the persisted projection cache only — never a // log load; a session without a cache row simply has no column. - const projections = listProjectionsFor(ctx, meta.id, undefined) + const projections = listProjectionsFor(ctx, meta, undefined) return { ...await summarizeCold(persistence, meta), ...projections === undefined ? {} : { projections }, diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a02267a6bf..21feaaf604 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,7 +7,6 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -38,16 +37,7 @@ export const sessionEventSchema = z.object({ surfaceOp: z.unknown().optional(), }) as unknown as z.ZodType -/** - * Projection-values passthrough (same posture as - * {@link sessionProjectionsBlockSchema}): each value already passed its - * unit's own schema on the host side; deep-validating here would import - * every domain's schema into the carrier. - */ -const projectionValuesSchema = - z.record(z.string(), z.unknown()) as unknown as z.ZodType> - -/** SessionSummary row of session.list. */ +/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, updatedAt: z.number(), @@ -55,8 +45,8 @@ export const sessionSummarySchema = z.object({ blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional(), - projections: projectionValuesSchema.optional(), -}) satisfies z.ZodType> + projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), +}) as unknown as z.ZodType> /** session.list request payload (cursor is a reserved seat, unimplemented in v1). */ export const sessionListRequestSchema = z.object({ @@ -64,9 +54,9 @@ export const sessionListRequestSchema = z.object({ }) satisfies z.ZodType>> /** session.list response value. */ -export const sessionListValueSchema = z.object({ +export const sessionListValueSchema: z.ZodType>> = z.object({ items: z.array(sessionSummarySchema), -}) satisfies z.ZodType>> +}) /** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index a6d5c1517c..ea5d81171d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -144,16 +144,17 @@ export interface SessionSummary { /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string /** - * Whole current value per projection key, with zero log loads: attached - * sessions read the registry's live watermark cut; cold sessions read the - * persisted projection cache's stored rows — as stale as that session's - * last durable checkpoint, never wrong, superseded by the history tail - * baseline the moment the session is opened. Absent when no value is - * available (no registry, no cache row for a cold session, or a fail-soft - * cache read miss); a listing client treats absence as "no title yet", - * exactly like a blank session. + * Projection baseline for this row, with zero log loads: attached sessions + * read the registry's live watermark cut; cold sessions read the persisted + * projection cache's stored rows — as stale as that session's last durable + * checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly + * seedable into the client's per-session value store under its + * higher-seq-wins rule (a list baseline can never overwrite a newer push + * frame). Absent when no value is available (no registry, no cache row for + * a cold session, or a fail-soft cache read miss); a listing client treats + * absence as "no title yet", exactly like a blank session. */ - projections?: Partial + projections?: SessionProjectionsBlock } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index caa957d05d..bcfc067ff5 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -126,14 +126,15 @@ describe('session.history projections block', () => { }) describe('session.list projections column', () => { - it('serves attached rows from the live registry cut', async () => { + it('serves attached rows from the live registry cut, watermarked for client seeding', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) const response = await api(ctx).sessions.list(request({})) if (!response.result.ok) throw new Error('unreachable') const row = response.result.value.items.find(item => item.sessionId === session.id) - expect(row?.projections?.['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) it('omits the column entirely when no registry is mounted', async () => { @@ -158,13 +159,17 @@ describe('session.list projections column', () => { readFrom: load, } as never) ctx.provide('sessionProjectionCache', { - cachedValues: (id: unknown) => (id === coldId ? { 'test/last-user': { text: 'cached' } } : {}), + // The carrier hands the listed header through as the identity witness. + cachedSnapshot: (meta: { id: unknown; createdAt: number }) => + (meta.id === coldId && meta.createdAt === 5 + ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } } + : undefined), } as never) const response = await api(ctx).sessions.list(request({})) if (!response.result.ok) throw new Error('unreachable') const row = response.result.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) - expect(row?.projections?.['test/last-user']).toEqual({ text: 'cached' }) + expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }) }) it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { From ee79b7a73aaadf10e442ac64d689bb620a38a9e3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:14:17 +0800 Subject: [PATCH 42/61] docs: regenerate the cordis catalogs for the reshaped cache read faces cachedSnapshot/coldSnapshot signature and JSDoc changes from the review fixes flow into the generated service catalog and model-facing api-catalog. --- docs/cordis-catalog/services.md | 28 ++++++++----------- .../cordis/tool-cordis/src/api-catalog.ts | 8 ++---- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a14a365da2..e6895f6a8d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1111,25 +1111,19 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../ The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. ```ts cordis-catalog -/** - * The stored checkpoint rows for one session, or an empty checkpoint when - * none is stored. Synchronous from the domain's in-memory state. - * @param id - the session whose cached rows are read. - * @returns the persisted `key → row` checkpoint (possibly empty). - */ -checkpointOf(id: SessionId): ProjectionCheckpoint - /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), as stale as the last durable - * checkpoint but never wrong. Synchronous — a listing over every stored - * session touches no log. Fresher paths (the history tail baseline, - * {@link coldSnapshot}) supersede these values whenever a session is - * actually opened. - * @param id - the session whose cached values are viewed. - * @returns whole values per key with a usable row; empty when none stored. + * rows (version-matching keys only), each cut carried with its watermark + * so a client value store can seed under its higher-seq-wins rule — as + * stale as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline, {@link coldSnapshot}) supersede these + * values whenever a session is actually opened. + * @param meta - the listed session's header (identity witness; no log read). + * @returns the cut (`asOfSeq` = lowest served-row watermark), or + * `undefined` when no usable row exists for this lifecycle. */ -cachedValues(id: SessionId): Partial +cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined /** * Durably checkpoint one live session NOW (both mandatory points call @@ -1156,7 +1150,7 @@ async write(session: Session): Promise async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise ``` -Types: [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) +Types: [Session](../core-data-structures/session.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 33179837b7..fb435e7ca2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -543,12 +543,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The persisted projection cache service.', methods: [ { - signature: 'checkpointOf(id: SessionId): ProjectionCheckpoint', - jsDoc: '/**\n * The stored checkpoint rows for one session, or an empty checkpoint when\n * none is stored. Synchronous from the domain\'s in-memory state.\n * @param id - the session whose cached rows are read.\n * @returns the persisted `key → row` checkpoint (possibly empty).\n */', - }, - { - signature: 'cachedValues(id: SessionId): Partial', - jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), as stale as the last durable\n * checkpoint but never wrong. Synchronous — a listing over every stored\n * session touches no log. Fresher paths (the history tail baseline,\n * {@link coldSnapshot}) supersede these values whenever a session is\n * actually opened.\n * @param id - the session whose cached values are viewed.\n * @returns whole values per key with a usable row; empty when none stored.\n */', + signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined', + jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), each cut carried with its watermark\n * so a client value store can seed under its higher-seq-wins rule — as\n * stale as the last durable checkpoint but never wrong, and never from an\n * unrelated log (the caller\'s header is the identity witness). Fresher\n * paths (the history tail baseline, {@link coldSnapshot}) supersede these\n * values whenever a session is actually opened.\n * @param meta - the listed session\'s header (identity witness; no log read).\n * @returns the cut (`asOfSeq` = lowest served-row watermark), or\n * `undefined` when no usable row exists for this lifecycle.\n */', }, { signature: 'async write(session: Session): Promise', From 019dd7d8949d0cfcbab3a72a9089afc903ddbe45 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:51:03 +0800 Subject: [PATCH 43/61] test,chore: clear the static and coverage lanes for the cache stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static: the cache package.json files array matches the workspace constraint shape, the unused dsh-storage-json devDependency is dropped (tests run on the memory backend), and docs/module-graph.md is regenerated for the new package edge. Coverage: two unreachable branches deleted rather than tested — coldSnapshot's floor-0 tail reuse (a baseSeq-0 restore never throws and an unrelated record still carries a usable watermark) and flushSoft's non-mandatory clean-skip (throttle triggers only fire dirty). New tests close the real gaps: write() on a never-dirty session and the non-JSON unit-state rejection, plugin disposal clearing armed interval timers, cachedSnapshot's all-version-mismatched and cwd-identity arms, the zero-units empty-log cut, the coordinator seek-hook ladder (suffix / not-found / plain failure / abort-reason relay), and the superseded- retirement race proving forget()'s exact-entry guard. --- docs/module-graph.md | 7 ++ .../tests/persistence.spec.ts | 119 ++++++++++++++++++ .../session-projection-cache/package.json | 2 - .../session-projection-cache/src/index.ts | 16 +-- .../tests/cache.spec.ts | 71 ++++++++++- pnpm-lock.yaml | 3 - 6 files changed, 205 insertions(+), 13 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 729745f7ad..255202bbae 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -211,6 +211,7 @@ flowchart TD end subgraph group_session_projection["packages/session-projection"] pkg_session_projection["session-projection"] + pkg_session_projection_cache["session-projection-cache"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -502,6 +503,11 @@ flowchart TD pkg_pty --> pkg_invariants pkg_scripts --> pkg_app_boot pkg_scripts --> pkg_invariants + pkg_session_projection_cache --> pkg_invariants + pkg_session_projection_cache --> pkg_session + pkg_session_projection_cache --> pkg_session_persistence + pkg_session_projection_cache --> pkg_session_projection + pkg_session_projection_cache --> pkg_storage_domain pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -1003,6 +1009,7 @@ flowchart TD | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | +| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 966060dfbb..bca32438f4 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -161,6 +161,13 @@ class ControlledBackend implements PersistenceBackend { repairAttempts = 0 beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise + /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ + seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise | undefined> + + loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise | undefined> { + if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') + return this.seekHook(id, fromSeq, signal) + } async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { await this.beforeLoadStored?.(++this.loadAttempts, signal) @@ -457,6 +464,58 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) + it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seek-read-from') + const log = oneTurnLog() + backend.store.set(id, { meta: meta(id), events: log }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + // Happy path through the hook: only the suffix comes back, detached. + backend.seekHook = async (hookId, fromSeq) => { + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) } + } + const suffix = await coordinator.readFrom(id, 3) + expect(suffix.events).toEqual(log.slice(3)) + // The hook's undefined is the seam's not-found. + await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found') + + // A hook failure with no cancellation in play propagates as-is. + const hookFailure = new Error('seek backend exploded') + backend.seekHook = () => Promise.reject(hookFailure) + await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure) + + // A hook failure after cancellation surfaces the caller's abort reason, + // not the backend's internal teardown error. The abort fires only once + // the hook is provably entered, so the failure exercises the catch (not + // the pre-invocation throwIfAborted). + const controller = new AbortController() + const reason = new Error('read-from cancelled mid-hook') + let hookEntered = false + backend.seekHook = async (_hookId, _fromSeq, signal) => { + hookEntered = true + await new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) + throw new Error('backend teardown after abort') + } + const pending = coordinator.readFrom(id, 0, controller.signal) + const observed = pending.catch((error: unknown) => error) + await vi.waitFor(() => { expect(hookEntered).toBe(true) }) + controller.abort(reason) + expect(await observed).toBe(reason) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('rejects a cancelled inspect while an in-flight retirement drain is still pending', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -541,6 +600,66 @@ describe('PersistenceCoordinator retirement', () => { } }) + it('a superseded retirement leaves the successor lifecycle\'s pending drain in place', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const internals = coordinator as unknown as CoordinatorInternals + const readGate = Promise.withResolvers() + + try { + const id = SessionId('superseded-retirement') + // First lifecycle: unmaterialized (zero events), so a same-id successor + // may legally reclaim the abandoned id later. + let first!: Session + const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { + first = inner.sessions.create(id) + }, { inject: ['sessions'] })) + await ctx.sessions.flush(first) + + // Occupy the per-id serialize chain with a gated read: everything the + // two retirements queue stays pending behind it. (Attempt counting + // starts here — an absent beforeLoadStored short-circuits the optional + // call without evaluating its ++ argument.) + backend.beforeLoadStored = async (attempt) => { + if (attempt === 1) await readGate.promise + } + const parked = coordinator.inspect(id).catch((error: unknown) => error) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + + // First retirement queues behind the gate and stays pending. + await firstFiber.dispose() + await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) }) + const firstRetirement = internals.retirements.get(id) + + // Successor lifecycle retires while the first drain is still in flight: + // retire() replaces the map entry synchronously. + const secondFiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(id) + }, { inject: ['sessions'] })) + await secondFiber.dispose() + await vi.waitFor(() => { + expect(internals.retirements.get(id)).not.toBe(firstRetirement) + }) + + // Release the chain: the first drain settles and its forget() must not + // delete the successor's entry (exact-entry guard); the successor's own + // forget() then clears the map. + readGate.resolve(true) + expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed + await firstRetirement + await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) }) + } finally { + readGate.resolve(true) + await backendFiber.dispose() + await ctx.fiber.dispose() + } + }) + it('a replacement queued before retirement cleanup still collides with the live owner', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json index 5080713412..d06a49def0 100644 --- a/packages/session-projection/session-projection-cache/package.json +++ b/packages/session-projection/session-projection-cache/package.json @@ -21,7 +21,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -46,7 +45,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index 7e217e314e..c598bd1312 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -186,9 +186,10 @@ export class SessionProjectionCache extends Service { restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) } catch { // The recoverable restore failures: an unrelated record, or a row - // overreaching the stored log end (or predating the floor). All - // resolve identically — discard the cache and refold the full log. - const whole = floor === 0 && related ? tail : await persistence.readFrom(id, 0, signal) + // overreaching the stored log end (or predating the floor). Both imply + // floor > 0 (baseSeq-0 restores never throw and an unrelated record + // still carried a usable watermark), so the full log is a fresh read. + const whole = await persistence.readFrom(id, 0, signal) restored = this.ctx.sessionProjections.restore({}, whole.events, 0) } await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') @@ -237,11 +238,12 @@ export class SessionProjectionCache extends Service { }, 'sessionProjectionCache.timers') } - /** One fail-soft durable checkpoint: skip when clean, log on failure. */ + /** + * One fail-soft durable checkpoint. Every caller has work by construction: + * the throttle triggers only fire dirty (markClean clears the timer with + * the counter) and the two mandatory points write unconditionally. + */ private async flushSoft(session: Session, trigger: string): Promise { - const state = this.dirty.get(session) - const mandatory = trigger === 'turn/end' || trigger === 'detach' - if (!mandatory && (state === undefined || state.pending === 0)) return try { await this.write(session) } catch (error) { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 94406b1803..e572f9a378 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -58,7 +58,8 @@ function fakePersistence(logs: Map) { } /** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */ -const headerOf = (id: SessionId, createdAt = 0) => ({ version: 0, id, createdAt }) +const headerOf = (id: SessionId, createdAt = 0, cwd?: string) => + ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } }) interface HarnessOptions { pool?: MemoryMediaPool @@ -166,6 +167,39 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) }) + it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => { + const { ctx, pool } = await harness() + // Never dirtied: no events — write() still lands the init-derived cut. + const clean = ctx.sessions.create(SessionId('clean-write')) + await ctx.sessionProjectionCache.write(clean) + expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + // A unit whose state violates the plain-JSON contract fails the write loud. + ctx.sessionProjections.register({ + key: 'cache-test/marks2' as never, + schema: { parse: (value: unknown) => value } as never, + init: () => new Map(), + apply: (state: unknown) => state, + view: () => null as never, + stateVersion: 1, + } as never) + await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable') + }) + + it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => { + vi.useFakeTimers() + const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } }) + const armed = ctx.sessions.create(SessionId('armed')) + const cleaned = ctx.sessions.create(SessionId('cleaned')) + mark(armed, ['pending']) // timer armed, no write yet + mark(cleaned, ['done']) + endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map + await vi.runAllTicks() + await fiber.dispose() + // The armed timer died with the plugin: advancing time writes nothing. + await vi.advanceTimersByTimeAsync(10_000) + expect(storedRows(pool, armed.id)).toBeUndefined() + }) + it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => { const { ctx, pool } = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) @@ -281,6 +315,41 @@ describe('SessionProjectionCache cold read', () => { expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 }) }) + it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } }) + const { cache } = await harness({ pool }) + expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined() + }) + + it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) + const { cache } = await harness({ pool }) + const id = SessionId('homed') + expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] }) + expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined() + }) + + it('dates an empty stored log at -1 in the zero-units topology', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['empty', [] as SessionEvent[]]]) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('sessionPersistence', fakePersistence(logs) as never) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty'))) + .resolves.toEqual({ asOfSeq: -1, values: {} }) + }) + it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const pool = new MemoryMediaPool() seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 408a990c35..6aa2b57331 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3493,9 +3493,6 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - '@deepseek-ai/dsh-storage-json': - specifier: workspace:^ - version: link:../../storage/storage-json cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From 931dd934e36107901a871d95b7d58ebdf518a7ba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:02:21 +0800 Subject: [PATCH 44/61] =?UTF-8?q?style:=20clear=20the=20lint=20lane=20?= =?UTF-8?q?=E2=80=94=20drop=20redundant=20assertions=20and=20non-thenable?= =?UTF-8?q?=20awaits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eslint --fix removed the no-unnecessary-type-assertion hits the review knives introduced; the two await-thenable errors were vi.runAllTicks() awaits in the cache spec, replaced with advanceTimersByTimeAsync(0) (the fake-timer-safe microtask drain). Repo-wide eslint, dual-aggregate tsc, and the focused 100% coverage set all green. --- .../session-projection-cache/tests/cache.spec.ts | 10 +++++----- .../session-projection/session-projection/src/index.ts | 4 ++-- .../session-projection/tests/registry.spec.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index e572f9a378..c7c7b8502f 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -39,7 +39,7 @@ const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', M key: 'cache-test/marks', schema: z.object({ marks: z.array(z.string()) }), init: () => null, - apply: (state, event) => (event.type === 'cache-test/mark' ? (event as SessionEvent<'cache-test/mark'>).data : state), + apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state), view: state => state ?? { marks: [] }, stateVersion, }) @@ -163,7 +163,7 @@ describe('SessionProjectionCache write policy', () => { await vi.advanceTimersByTimeAsync(249) expect(storedRows(pool, session.id)).toBeUndefined() await vi.advanceTimersByTimeAsync(1) - await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(0) expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) }) @@ -181,7 +181,7 @@ describe('SessionProjectionCache write policy', () => { apply: (state: unknown) => state, view: () => null as never, stateVersion: 1, - } as never) + }) await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable') }) @@ -193,7 +193,7 @@ describe('SessionProjectionCache write policy', () => { mark(armed, ['pending']) // timer armed, no write yet mark(cleaned, ['done']) endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map - await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(0) await fiber.dispose() // The armed timer died with the plugin: advancing time writes nothing. await vi.advanceTimersByTimeAsync(10_000) @@ -224,7 +224,7 @@ describe('SessionProjectionCache cold read', () => { { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, ] for (const m of marks) { - events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } } as SessionEvent) + events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } }) } events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } }) return events diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 5b7a3c1f4c..4e5274b463 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -300,7 +300,7 @@ export class SessionProjectionRegistry extends Service { if (row === undefined || row.stateVersion !== def.stateVersion) continue values[def.key] = def.schema.parse(def.view(row.state)) } - return values as Partial + return values } /** @@ -352,7 +352,7 @@ export class SessionProjectionRegistry extends Service { refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } } return { - snapshot: { asOfSeq: endSeq, values: values as ProjectionSnapshot['values'] }, + snapshot: { asOfSeq: endSeq, values: values }, checkpoint: refreshed, } } diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 17ee5aaf84..2f2ab15dde 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -224,7 +224,7 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ - { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } } as SessionEvent, + { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] // marks row usable (watermark 2, tail starts at 3); count row mismatched — but @@ -236,8 +236,8 @@ describe('SessionProjectionRegistry drive', () => { // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } } as SessionEvent, - { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } } as SessionEvent, + { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } }, + { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } }, ...tail, ] const { snapshot, checkpoint } = ctx.sessionProjections.restore({ From b4bc4f382e3f194f9ed4da51c18e605d0fe52809 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:45:35 +0800 Subject: [PATCH 45/61] refactor(session-projection): compact checkpoint row fields to ver/seq/val MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted row (sessionId, key, stateVersion, observedSeq, state) becomes (sessionId, key, ver, seq, val) — the cache medium repeats these three names for every unit of every session, so the long forms dominated the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec rename together; the domain spec bumps to v3 (cache semantics: the old medium is discarded, not migrated). The unit-facing declaration keeps stateVersion — only the persisted/checkpoint row shape changes. --- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 4 +- ...7-session-projection-and-command-log.zh.md | 4 +- docs/cordis-catalog/services.md | 18 ++--- .../cordis/tool-cordis/src/api-catalog.ts | 10 +-- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 4 +- .../session-projection-cache/README.zh.md | 4 +- .../session-projection-cache/src/index.ts | 6 +- .../session-projection-cache/src/invariant.ts | 2 +- .../session-projection-cache/src/spec.ts | 23 +++---- .../tests/cache.spec.ts | 34 +++++----- .../session-projection/README.i18n.yaml | 4 +- .../session-projection/README.md | 4 +- .../session-projection/README.zh.md | 4 +- .../session-projection/src/index.ts | 65 +++++++++---------- .../session-projection/tests/registry.spec.ts | 48 +++++++------- 17 files changed, 121 insertions(+), 121 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 2476db5785..8f720e33b5 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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 .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117 -2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1 +2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 +2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 51cc60208e..6a073c956c 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -51,7 +51,7 @@ declare module 'cordis' { - Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's. - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. -- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. +- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. - The package owns `./invariant` (every served key has a live registration). @@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a 2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile). 3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1. 4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject). -5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay. +5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay. ## Alternatives considered diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 71f6f6ea94..500f07968d 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -51,7 +51,7 @@ declare module 'cordis' { - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。 - **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 -- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 +- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 @@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` 2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。 3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。 4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。 -5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。 +5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。 ## Alternatives considered diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e6895f6a8d..eef530b0df 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1191,9 +1191,9 @@ snapshot(session: Session): ProjectionSnapshot * State-level checkpoint of every registered unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory * log). This is the write side of the persisted projection cache: the - * returned rows are the `(key → {stateVersion, observedSeq, state})` part - * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. Every `state` is a DETACHED structured clone — never the live + * returned rows are the `(key → {ver, seq, val})` part of the durable + * `(sessionId, key, ver, seq, val)` + * rows. Every `val` is a DETACHED structured clone — never the live * cell reference: the watermark cache is this registry's authoritative * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit @@ -1206,7 +1206,7 @@ checkpoint(session: Session): ProjectionCheckpoint /** * The stored seq a {@link restore} tail read over `checkpoint` must start * at: one event BELOW the lowest usable watermark (a row is usable when - * its `stateVersion` matches the live unit; an absent or mismatched row + * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row * pulls the floor to `0` — that key must refold the full log). The * one-below anchor is load-bearing: the tail then proves how far the * stored log still extends, so {@link restore} can detect a log that @@ -1223,7 +1223,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered - * unit whose row's `stateVersion` matches, serve the schema-validated + * unit whose row's `ver` matches, serve the schema-validated * `view` of the stored state; mismatched or absent rows leave their key * absent (a cold or listing consumer treats it as not-yet-available and a * fuller read path refolds it). The zero-I/O rung of the read ladder — @@ -1241,9 +1241,9 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial * `readFrom(id, restoreFloor(checkpoint))` and that same floor as * `baseSeq`; the floor's one-below anchor makes the supplied end honest, * so a shrunk log is detected here. A row is usable iff its - * `stateVersion` matches the live unit, it does not predate `baseSeq` - * (`observedSeq >= baseSeq - 1`), and it does not claim events past the - * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` + * (`seq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`seq <= endSeq`); an unusable row is discarded * and its key refolds from `init` — which is only sound over the full * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads * from seq 0, e.g. after a crash-repair truncation shrank the log below @@ -1260,7 +1260,7 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index fb435e7ca2..c1011c2326 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -574,19 +574,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', - jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', + jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {ver, seq, val})` part of the durable\n * `(sessionId, key, ver, seq, val)`\n * rows. Every `val` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', }, { signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', - jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', + jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', }, { signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', - jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', + jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `ver` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', }, { signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', - jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', + jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq`\n * (`seq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`seq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', }, ], }, @@ -1899,7 +1899,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionCheckpointRow', - declaration: 'export interface ProjectionCheckpointRow {\n stateVersion: number;\n observedSeq: number;\n state: unknown;\n}', + declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: number;\n val: unknown;\n}', }, { name: 'ProjectionDefinition', diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 2214de288d..c1bdd6c5b8 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/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 packages/session-projection/session-projection-cache/README.md -README.md: a10bb858159e9581c815d2532a893208c45e788a -README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff +README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e +README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4 diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index a10bb85815..5d4ad07fab 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -4,10 +4,10 @@ English | [中文](README.zh.md) The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). -A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: +A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. -- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. +- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. - **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index 191efbc2c7..ab4076df28 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -4,10 +4,10 @@ 持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 -一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: +一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 -- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 +- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。 diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index c598bd1312..03b3ed26ee 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -3,10 +3,10 @@ * checkpoints of every registered projection unit's state, one record per * session on the domain data form (`session_projcache` domain — the shipped * json backend lands it beside `workspace.json`). The cache is a fold - * shortcut, never an authority: a row is possibly stale (its `observedSeq` + * shortcut, never an authority: a row is possibly stale (its `seq` * says how stale) but never wrong, so every write path is fail-soft (a lost * write costs a longer tail replay on the next cold read) and a - * `stateVersion` mismatch discards the row instead of migrating it. Design + * `ver` mismatch discards the row instead of migrating it. Design * authority: the session-projection RFC * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * @module @deepseek-ai/dsh-session-projection-cache @@ -125,7 +125,7 @@ export class SessionProjectionCache extends Service { // The block carries ONE cut: the lowest served watermark is the seq every // value is at least current as of (under-claiming is safe under // higher-seq-wins; over-claiming would let a stale value outrank pushes). - const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq)) + const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq)) return { asOfSeq, values } } diff --git a/packages/session-projection/session-projection-cache/src/invariant.ts b/packages/session-projection/session-projection-cache/src/invariant.ts index a5a5515f22..8a119044d8 100644 --- a/packages/session-projection/session-projection-cache/src/invariant.ts +++ b/packages/session-projection/session-projection-cache/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the cache's correctness relation (a stored row equals - * the registry fold at its `observedSeq`) is only checkable by re-running the + * the registry fold at its `seq` watermark) is only checkable by re-running the * fold over the persisted log — duplicating the implementation rather than * detecting drift — and its staleness is by design (fail-soft writes). The * durable boundary is already schema-validated by the storage-domain layer diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts index 72796b6c4b..5e41763f80 100644 --- a/packages/session-projection/session-projection-cache/src/spec.ts +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -1,7 +1,7 @@ /** * The session-projcache domain declaration: one `sessions` table keyed by * {@link SessionId}, each record the full projection checkpoint for one - * session (`key → {stateVersion, observedSeq, state}` rows). The spec object + * session (`key → {ver, seq, val}` rows). The spec object * is the single source of the domain's identity, version, and record schema; * the storage-domain routing decides the medium (the shipped composition's * json backend lands it at `/session_projcache.json`, beside @@ -14,17 +14,17 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' /** - * One persisted checkpoint row (the RFC's `(sessionId, key, stateVersion, - * observedSeq, state)` minus the two record keys). `state` is the unit's - * internal state — plain JSON by the unit contract; `z.json()` enforces that - * at the durable boundary. A row is never wrong, only possibly stale: - * `observedSeq` says exactly how stale, and a `stateVersion` mismatch + * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` + * minus the two record keys). `val` is the unit's internal state — plain + * JSON by the unit contract; `z.json()` enforces that at the durable + * boundary. A row is never wrong, only possibly stale: `seq` says exactly + * how stale, and a `ver` mismatch against the live unit's `stateVersion` * discards it at read time (never a migration). */ export const checkpointRow = z.object({ - stateVersion: z.number().int().nonnegative(), - observedSeq: z.number().int().gte(-1), - state: z.json(), + ver: z.number().int().nonnegative(), + seq: z.number().int().gte(-1), + val: z.json(), }) /** @@ -61,10 +61,11 @@ export type CheckpointRecord = z.infer /** * The session-projcache domain spec. Version bumps discard the whole medium * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). v2 added the record's log-identity binding. + * never a wrong value). v2 added the record's log-identity binding; v3 + * renamed the row fields to `ver`/`seq`/`val`. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', - version: 2, + version: 3, tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index c7c7b8502f..8cf9345772 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -100,7 +100,7 @@ function storedRecord(pool: MemoryMediaPool, id: Session['id']) { return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as { identity: { createdAt: number; cwd?: string } - rows: Record + rows: Record } | undefined } @@ -126,7 +126,7 @@ describe('SessionProjectionCache write policy', () => { const end = endTurn(session) await settle() const rows = storedRows(pool, session.id) - expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } }) + expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } }) }) it('writes at session disposal (detach, the live-to-cold moment)', async () => { @@ -140,7 +140,7 @@ describe('SessionProjectionCache write policy', () => { mark(session, ['live']) await owner.dispose() await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] }) }) it('flushes when the in-turn event count reaches the configured threshold', async () => { @@ -152,7 +152,7 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)).toBeUndefined() mark(session, ['3']) await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] }) }) it('flushes on the configured interval when the count threshold is not reached', async () => { @@ -164,7 +164,7 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)).toBeUndefined() await vi.advanceTimersByTimeAsync(1) await vi.advanceTimersByTimeAsync(0) - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] }) }) it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => { @@ -172,7 +172,7 @@ describe('SessionProjectionCache write policy', () => { // Never dirtied: no events — write() still lands the init-derived cut. const clean = ctx.sessions.create(SessionId('clean-write')) await ctx.sessionProjectionCache.write(clean) - expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. ctx.sessionProjections.register({ key: 'cache-test/marks2' as never, @@ -214,7 +214,7 @@ describe('SessionProjectionCache write policy', () => { mark(session, ['y']) endTurn(session) await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] }) }) }) @@ -234,10 +234,10 @@ describe('SessionProjectionCache cold read', () => { function seedRow( pool: MemoryMediaPool, id: string, - row: { stateVersion: number; observedSeq: number; state: unknown }, + row: { ver: number; seq: number; val: unknown }, identity: { createdAt: number; cwd?: string } = { createdAt: 0 }, ): void { - pool.versions.set('session_projcache', 2) + pool.versions.set('session_projcache', 3) pool.media.set('session_projcache', { tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]), global: null, @@ -248,7 +248,7 @@ describe('SessionProjectionCache cold read', () => { const pool = new MemoryMediaPool() const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]]) // A warm-era checkpoint at watermark 1 (only ['a'] folded). - seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } }) + seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } }) const { cache, persistence, pool: samePool } = await harness({ pool, logs }) const id = SessionId('cold') const snapshot = await cache.coldSnapshot(id) @@ -258,13 +258,13 @@ describe('SessionProjectionCache cold read', () => { expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined) // Write-back: the stored row advanced to the served cut. expect(storedRows(samePool, id)?.['cache-test/marks']) - .toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } }) + .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } }) }) it('discards a version-mismatched row and refolds the full log', async () => { const pool = new MemoryMediaPool() const logs = new Map([['bumped', storedLog([['a']])]]) - seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } }) + seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } }) const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 }) const snapshot = await cache.coldSnapshot(SessionId('bumped')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) @@ -276,7 +276,7 @@ describe('SessionProjectionCache cold read', () => { it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => { const pool = new MemoryMediaPool() const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2 - seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } }) + seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } }) const { cache, persistence } = await harness({ pool, logs }) const snapshot = await cache.coldSnapshot(SessionId('shrunk')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) @@ -307,7 +307,7 @@ describe('SessionProjectionCache cold read', () => { const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0 // A checkpoint from a PRIOR lifecycle of the same id (different createdAt): // its rows pass every watermark check, but the identity does not match. - seedRow(pool, 'reborn', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 }) + seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 }) const { cache, pool: samePool } = await harness({ pool, logs }) const snapshot = await cache.coldSnapshot(SessionId('reborn')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] }) @@ -317,14 +317,14 @@ describe('SessionProjectionCache cold read', () => { it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } }) + seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } }) const { cache } = await harness({ pool }) expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined() }) it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) + seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) const { cache } = await harness({ pool }) const id = SessionId('homed') expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] }) @@ -352,7 +352,7 @@ describe('SessionProjectionCache cold read', () => { it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) + seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } }) const { cache } = await harness({ pool }) const id = SessionId('listed') // Matching header: values plus the watermark the client seeds under. diff --git a/packages/session-projection/session-projection/README.i18n.yaml b/packages/session-projection/session-projection/README.i18n.yaml index 7a54d3214f..a15bee5bac 100644 --- a/packages/session-projection/session-projection/README.i18n.yaml +++ b/packages/session-projection/session-projection/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 packages/session-projection/session-projection/README.md -README.md: 2e026aab55933c96ba961481f9597bc18cbbe910 -README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce +README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e +README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025 diff --git a/packages/session-projection/session-projection/README.md b/packages/session-projection/session-projection/README.md index 2e026aab55..f4898b8e56 100644 --- a/packages/session-projection/session-projection/README.md +++ b/packages/session-projection/session-projection/README.md @@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly. -- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. +- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. - **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. @@ -43,5 +43,5 @@ None; projections never assemble or send provider requests. - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. -- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase. +- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session-projection/session-projection/README.zh.md b/packages/session-projection/session-projection/README.zh.md index a3e0b0f464..385862868d 100644 --- a/packages/session-projection/session-projection/README.zh.md +++ b/packages/session-projection/session-projection/README.zh.md @@ -23,7 +23,7 @@ - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 - **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 -- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 +- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 @@ -43,5 +43,5 @@ - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。 -- **持久投影缓存属于后续阶段**——cell 目前只活在内存里;重启后首次触达时靠折叠内存日志重建。`stateVersion` 字段是为该阶段预先声明的失效锚点。 +- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 4e5274b463..dbf0c5ee6e 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -66,9 +66,9 @@ export interface ProjectionDefinition { view(state: S): SessionProjectionMap[K] /** * Persisted-cache invalidation anchor: bump whenever the state shape or the - * fold semantics change, so persisted `(sessionId, key, stateVersion, - * observedSeq, state)` rows from an older unit are discarded instead of - * being forward-applied into garbage. Non-negative integer. + * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` + * rows from an older unit are discarded instead of being forward-applied + * into garbage. Non-negative integer. */ stateVersion: number } @@ -99,20 +99,19 @@ export interface ProjectionSnapshot { /** * One unit's checkpoint: its internal state (plain JSON by the unit - * contract), the seq of the last event folded into it, and the + * contract), the seq of the last event folded into it, and the unit * `stateVersion` that produced it — the persisted projection-cache row - * `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer - * keys. A row is never authoritative, only a fold shortcut: `restore` - * discards it on a `stateVersion` mismatch or when it claims events past the - * stored log end. + * `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is + * never authoritative, only a fold shortcut: `restore` discards it on a + * version mismatch or when it claims events past the stored log end. */ export interface ProjectionCheckpointRow { /** The registering unit's `stateVersion` at fold time. */ - stateVersion: number - /** Seq of the last event folded into `state`; -1 for the empty log. */ - observedSeq: number + ver: number + /** Seq of the last event folded into `val`; -1 for the empty log. */ + seq: number /** The unit's internal state — plain JSON per the unit contract. */ - state: unknown + val: unknown } /** Checkpoint rows keyed by projection key (one session's persisted cache value). */ @@ -231,9 +230,9 @@ export class SessionProjectionRegistry extends Service { * State-level checkpoint of every registered unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory * log). This is the write side of the persisted projection cache: the - * returned rows are the `(key → {stateVersion, observedSeq, state})` part - * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. Every `state` is a DETACHED structured clone — never the live + * returned rows are the `(key → {ver, seq, val})` part of the durable + * `(sessionId, key, ver, seq, val)` + * rows. Every `val` is a DETACHED structured clone — never the live * cell reference: the watermark cache is this registry's authoritative * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit @@ -246,9 +245,9 @@ export class SessionProjectionRegistry extends Service { for (const registration of this.registrations.values()) { const cell = this.cellFor(registration, session) rows[registration.def.key] = { - stateVersion: registration.def.stateVersion, - observedSeq: cell.observedSeq, - state: structuredClone(cell.state), + ver: registration.def.stateVersion, + seq: cell.observedSeq, + val: structuredClone(cell.state), } } return rows @@ -257,7 +256,7 @@ export class SessionProjectionRegistry extends Service { /** * The stored seq a {@link restore} tail read over `checkpoint` must start * at: one event BELOW the lowest usable watermark (a row is usable when - * its `stateVersion` matches the live unit; an absent or mismatched row + * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row * pulls the floor to `0` — that key must refold the full log). The * one-below anchor is load-bearing: the tail then proves how far the * stored log still extends, so {@link restore} can detect a log that @@ -274,8 +273,8 @@ export class SessionProjectionRegistry extends Service { let floor: number | undefined for (const registration of this.registrations.values()) { const row = checkpoint[registration.def.key] - const need = row !== undefined && row.stateVersion === registration.def.stateVersion - ? Math.max(row.observedSeq + 1, 0) + const need = row !== undefined && row.ver === registration.def.stateVersion + ? Math.max(row.seq + 1, 0) : 0 floor = floor === undefined ? need : Math.min(floor, need) } @@ -284,7 +283,7 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered - * unit whose row's `stateVersion` matches, serve the schema-validated + * unit whose row's `ver` matches, serve the schema-validated * `view` of the stored state; mismatched or absent rows leave their key * absent (a cold or listing consumer treats it as not-yet-available and a * fuller read path refolds it). The zero-I/O rung of the read ladder — @@ -297,8 +296,8 @@ export class SessionProjectionRegistry extends Service { for (const registration of this.registrations.values()) { const def = registration.def const row = checkpoint[def.key] - if (row === undefined || row.stateVersion !== def.stateVersion) continue - values[def.key] = def.schema.parse(def.view(row.state)) + if (row === undefined || row.ver !== def.stateVersion) continue + values[def.key] = def.schema.parse(def.view(row.val)) } return values } @@ -311,9 +310,9 @@ export class SessionProjectionRegistry extends Service { * `readFrom(id, restoreFloor(checkpoint))` and that same floor as * `baseSeq`; the floor's one-below anchor makes the supplied end honest, * so a shrunk log is detected here. A row is usable iff its - * `stateVersion` matches the live unit, it does not predate `baseSeq` - * (`observedSeq >= baseSeq - 1`), and it does not claim events past the - * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` + * (`seq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`seq <= endSeq`); an unusable row is discarded * and its key refolds from `init` — which is only sound over the full * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads * from seq 0, e.g. after a crash-repair truncation shrank the log below @@ -334,22 +333,22 @@ export class SessionProjectionRegistry extends Service { const def = registration.def const row = checkpoint[def.key] const usable = row !== undefined - && row.stateVersion === def.stateVersion - && row.observedSeq >= baseSeq - 1 - && row.observedSeq <= endSeq + && row.ver === def.stateVersion + && row.seq >= baseSeq - 1 + && row.seq <= endSeq if (!usable && baseSeq > 0) { throw new Error( `session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: ` + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? row.state : def.init() - const from = usable ? row.observedSeq : baseSeq - 1 + let state = usable ? row.val : def.init() + const from = usable ? row.seq : baseSeq - 1 for (const event of events) { if (event.seq > from) state = def.apply(state, event) } values[def.key] = def.schema.parse(def.view(state)) - refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } + refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } } return { snapshot: { asOfSeq: endSeq, values: values }, diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 2f2ab15dde..90b8de4a40 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -175,11 +175,11 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) const markEvent = mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) - expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } }) - expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 }) + expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } }) + expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 }) // Empty log: init-derived state at watermark -1. const fresh = ctx.sessions.create() - expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null }) }) it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { @@ -188,11 +188,11 @@ describe('SessionProjectionRegistry drive', () => { mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) // Hostile (or merely careless) consumer mutates the handed-out state. - ;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED') + ;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED') // The registry's authoritative cell is untouched: snapshot and a fresh // checkpoint both still serve the committed value. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] }) - expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] }) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] }) }) it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { @@ -204,18 +204,18 @@ describe('SessionProjectionRegistry drive', () => { // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } }, - 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + 'test/marks': { ver: 1, seq: 10, val: { marks: [] } }, + 'test/count': { ver: 1, seq: 5, val: 6 }, })).toBe(5) // A version-mismatched row forces that key back to a full refold. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } }, - 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + 'test/marks': { ver: 2, seq: 10, val: { marks: [] } }, + 'test/count': { ver: 1, seq: 5, val: 6 }, })).toBe(0) // A fresh (-1) row still needs the whole tail from 0. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 1, observedSeq: -1, state: null }, - 'test/count': { stateVersion: 1, observedSeq: -1, state: 0 }, + 'test/marks': { ver: 1, seq: -1, val: null }, + 'test/count': { ver: 1, seq: -1, val: 0 }, })).toBe(0) }) @@ -230,8 +230,8 @@ describe('SessionProjectionRegistry drive', () => { // marks row usable (watermark 2, tail starts at 3); count row mismatched — but // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read. expect(() => ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } }, - 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, + 'test/count': { ver: 99, seq: 2, val: 3 }, }, tail, 3)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ @@ -241,15 +241,15 @@ describe('SessionProjectionRegistry drive', () => { ...tail, ] const { snapshot, checkpoint } = ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } }, - 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, + 'test/count': { ver: 99, seq: 2, val: 3 }, }, full, 0) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events // The refreshed rows sit at the served cut, ready for a durable write-back. - expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } }) - expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 }) + expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } }) + expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) }) it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { @@ -257,8 +257,8 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const rows = { - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, - 'test/count': { stateVersion: 1, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: 2, val: 3 }, } const tail: SessionEvent[] = [ { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -273,8 +273,8 @@ describe('SessionProjectionRegistry drive', () => { // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. const { snapshot: current } = ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, - 'test/count': { stateVersion: 1, observedSeq: 4, state: 5 }, + 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: 4, val: 5 }, }, [], 5) expect(current.asOfSeq).toBe(4) expect(current.values['test/count']).toBe(5) @@ -285,8 +285,8 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } }, - 'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent + 'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } }, + 'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent }) expect(values['test/marks']).toEqual({ marks: ['stored'] }) expect('test/count' in values).toBe(false) @@ -296,7 +296,7 @@ describe('SessionProjectionRegistry drive', () => { it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) - const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } } + const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… const floor = ctx.sessionProjections.restoreFloor(rows) From 5ea161fa681dd5b6f63ee30d71f3c8eed45d716c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:03:23 +0800 Subject: [PATCH 46/61] vendor: support native TypeScript source loading --- docs/cordis-catalog/core/context.md | 10 +++---- docs/cordis-catalog/core/events.md | 18 ++++++------ docs/cordis-catalog/core/fiber.md | 44 ++++++++++++++-------------- docs/cordis-catalog/core/registry.md | 8 ++--- vendor/README.md | 1 + vendor/cordis/src/context.ts | 4 +-- vendor/cordis/src/events.ts | 3 +- vendor/cordis/src/fiber.ts | 9 +++--- vendor/cordis/src/logger.ts | 2 +- vendor/cordis/src/reflect.ts | 3 +- vendor/cordis/src/registry.ts | 5 ++-- vendor/cordis/src/service.ts | 2 +- vendor/hmr/src/error.ts | 2 +- vendor/hmr/src/index.ts | 8 ++--- vendor/include/src/index.ts | 2 +- vendor/loader/src/config/group.ts | 2 +- vendor/loader/src/config/isolate.ts | 2 +- vendor/loader/src/config/tree.ts | 4 +-- vendor/loader/src/index.ts | 4 +-- vendor/loader/src/internal.ts | 4 +-- vendor/schemastery/package.json | 1 + vendor/schemastery/src/index.ts | 4 +-- vendor/schemastery/tsdown.config.ts | 7 ++--- 23 files changed, 77 insertions(+), 72 deletions(-) diff --git a/docs/cordis-catalog/core/context.md b/docs/cordis-catalog/core/context.md index f6b249c738..c0c1690c8f 100644 --- a/docs/cordis-catalog/core/context.md +++ b/docs/cordis-catalog/core/context.md @@ -256,7 +256,7 @@ Read a service from the store without the inject requirement. **Returns** the service value, or `undefined` when not (yet) provided. -[Source](../../../vendor/cordis/src/reflect.ts#L16) +[Source](../../../vendor/cordis/src/reflect.ts#L17) ### ctx.set(name, value) @@ -281,7 +281,7 @@ Only the fiber that provided the service may set it; setting an unprovided name - `name` — the service name. - `value` — the new service value. -[Source](../../../vendor/cordis/src/reflect.ts#L28) +[Source](../../../vendor/cordis/src/reflect.ts#L29) ### ctx.provide(name, value) @@ -311,7 +311,7 @@ The service becomes visible to dependents in the same isolation scope once the f **Returns** a disposer that unregisters the service. -[Source](../../../vendor/cordis/src/reflect.ts#L43) +[Source](../../../vendor/cordis/src/reflect.ts#L44) ### ctx.accessor(name, options) @@ -335,7 +335,7 @@ The accessor is removed when the current fiber unloads. Throws if the name is al - `name` — the context property name. - `options` — the `get` hook and optional `set` hook. -[Source](../../../vendor/cordis/src/reflect.ts#L55) +[Source](../../../vendor/cordis/src/reflect.ts#L56) ### ctx.mixin(name, mixins) @@ -361,4 +361,4 @@ Each mixed-in key becomes an accessor that forwards to the service (binding meth - `name` — the context property holding the source service. - `mixins` — keys to forward, or a source-key → ctx-key map. -[Source](../../../vendor/cordis/src/reflect.ts#L66) +[Source](../../../vendor/cordis/src/reflect.ts#L67) diff --git a/docs/cordis-catalog/core/events.md b/docs/cordis-catalog/core/events.md index 2fb64e78a2..68cd109e8e 100644 --- a/docs/cordis-catalog/core/events.md +++ b/docs/cordis-catalog/core/events.md @@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently. **Returns** a promise resolving once every listener has settled. -[Source](../../../vendor/cordis/src/events.ts#L43) +[Source](../../../vendor/cordis/src/events.ts#L44) ### ctx.emit(name, ...args) @@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values. - `name` — the event name. - `args` — arguments passed to every listener. -[Source](../../../vendor/cordis/src/events.ts#L52) +[Source](../../../vendor/cordis/src/events.ts#L53) ### ctx.serial(name, ...args) @@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L62) +[Source](../../../vendor/cordis/src/events.ts#L63) ### ctx.bail(name, ...args) @@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L72) +[Source](../../../vendor/cordis/src/events.ts#L73) ### ctx.waterfall(name, ...args) @@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis **Returns** the outermost listener's return value. -[Source](../../../vendor/cordis/src/events.ts#L85) +[Source](../../../vendor/cordis/src/events.ts#L86) ### ctx.on(name, listener, options?) @@ -144,7 +144,7 @@ Register an event listener owned by the current fiber. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L96) +[Source](../../../vendor/cordis/src/events.ts#L97) ### ctx.once(name, listener, options?) @@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L105) +[Source](../../../vendor/cordis/src/events.ts#L106) ## EventOptions @@ -184,7 +184,7 @@ interface EventOptions { } ``` -[Source](../../../vendor/cordis/src/events.ts#L111) +[Source](../../../vendor/cordis/src/events.ts#L112) ## DispatchMode @@ -204,4 +204,4 @@ Event dispatch strategy used by the event service. type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` -[Source](../../../vendor/cordis/src/events.ts#L31) +[Source](../../../vendor/cordis/src/events.ts#L32) diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index d865ce01fc..3cca4e8b86 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### ctx.fiber @@ -45,7 +45,7 @@ fiber: Fiber The fiber (plugin runtime instance) that owns this context. -[Source](../../../vendor/cordis/src/fiber.ts#L11) +[Source](../../../vendor/cordis/src/fiber.ts#L12) ## The Fiber class @@ -53,7 +53,7 @@ Runtime instance of one plugin application. A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. -[Source](../../../vendor/cordis/src/fiber.ts#L183) +[Source](../../../vendor/cordis/src/fiber.ts#L184) ### fiber.uid @@ -64,7 +64,7 @@ public uid: number | null Unique id within the registry; 0 for the root fiber, `null` once disposed. -[Source](../../../vendor/cordis/src/fiber.ts#L185) +[Source](../../../vendor/cordis/src/fiber.ts#L186) ### fiber.ctx @@ -75,7 +75,7 @@ public readonly ctx: Context The context this fiber's plugin runs in (extends the parent context). -[Source](../../../vendor/cordis/src/fiber.ts#L187) +[Source](../../../vendor/cordis/src/fiber.ts#L188) ### fiber.config @@ -86,7 +86,7 @@ public config: any The validated plugin config (updated by `update()`). -[Source](../../../vendor/cordis/src/fiber.ts#L189) +[Source](../../../vendor/cordis/src/fiber.ts#L190) ### fiber.state @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../../vendor/cordis/src/fiber.ts#L191) +[Source](../../../vendor/cordis/src/fiber.ts#L192) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../../vendor/cordis/src/fiber.ts#L193) +[Source](../../../vendor/cordis/src/fiber.ts#L194) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../../vendor/cordis/src/fiber.ts#L195) +[Source](../../../vendor/cordis/src/fiber.ts#L196) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../../vendor/cordis/src/fiber.ts#L197) +[Source](../../../vendor/cordis/src/fiber.ts#L198) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../../vendor/cordis/src/fiber.ts#L340) +[Source](../../../vendor/cordis/src/fiber.ts#L341) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../../vendor/cordis/src/fiber.ts#L355) +[Source](../../../vendor/cordis/src/fiber.ts#L356) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../../vendor/cordis/src/fiber.ts#L572) +[Source](../../../vendor/cordis/src/fiber.ts#L573) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../../vendor/cordis/src/fiber.ts#L701) +[Source](../../../vendor/cordis/src/fiber.ts#L702) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../../vendor/cordis/src/fiber.ts#L715) +[Source](../../../vendor/cordis/src/fiber.ts#L716) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../../vendor/cordis/src/fiber.ts#L733) +[Source](../../../vendor/cordis/src/fiber.ts#L734) ## Effect @@ -292,7 +292,7 @@ type Effect = | AsyncEffect ``` -[Source](../../../vendor/cordis/src/fiber.ts#L82) +[Source](../../../vendor/cordis/src/fiber.ts#L83) ## Disposable @@ -310,7 +310,7 @@ Disposers run in reverse registration order when the owning fiber unloads; they type Disposable = () => T ``` -[Source](../../../vendor/cordis/src/fiber.ts#L73) +[Source](../../../vendor/cordis/src/fiber.ts#L74) ## EffectMeta @@ -326,7 +326,7 @@ interface EffectMeta { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L95) +[Source](../../../vendor/cordis/src/fiber.ts#L96) ## CordisError @@ -352,7 +352,7 @@ namespace CordisError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L156) +[Source](../../../vendor/cordis/src/fiber.ts#L157) ## ValidationError @@ -372,4 +372,4 @@ class ValidationError extends TypeError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L18) +[Source](../../../vendor/cordis/src/fiber.ts#L19) diff --git a/docs/cordis-catalog/core/registry.md b/docs/cordis-catalog/core/registry.md index 2772dca723..6b2e298c72 100644 --- a/docs/cordis-catalog/core/registry.md +++ b/docs/cordis-catalog/core/registry.md @@ -30,7 +30,7 @@ Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloade **Returns** the fiber; awaiting it settles once loading finished. -[Source](../../../vendor/cordis/src/registry.ts#L175) +[Source](../../../vendor/cordis/src/registry.ts#L176) ### ctx.plugin(plugin, ...args) @@ -53,7 +53,7 @@ Load a plugin in the current context. **Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). -[Source](../../../vendor/cordis/src/registry.ts#L184) +[Source](../../../vendor/cordis/src/registry.ts#L185) ## Plugin @@ -118,7 +118,7 @@ namespace Plugin { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L91) +[Source](../../../vendor/cordis/src/registry.ts#L92) ## Inject @@ -149,4 +149,4 @@ namespace Inject { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L18) +[Source](../../../vendor/cordis/src/registry.ts#L19) diff --git a/vendor/README.md b/vendor/README.md index 1f4d61e6b1..a7e3183841 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,6 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. ## Sync procedure diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 514b1e062a..919ae65db3 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,8 +1,8 @@ -import { Dict } from 'cosmokit' +import type { Dict } from 'cosmokit' import { EventsService } from './events.ts' import { LoggerService } from './logger.ts' import { ReflectService } from './reflect.ts' -import { InjectKey, RegistryService } from './registry.ts' +import { RegistryService, type InjectKey } from './registry.ts' import { getTraceable, symbols } from './utils.ts' import { Fiber } from './fiber.ts' diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 09589d69da..7831fa75d1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,4 +1,5 @@ -import { defineProperty, Promisify } from 'cosmokit' +import { defineProperty } from 'cosmokit' +import type { Promisify } from 'cosmokit' import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 6beb7bbc24..61de8bed04 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,9 +1,10 @@ -import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable } from 'cosmokit' +import type { Awaitable, Dict } from 'cosmokit' import { Context } from './context.ts' -import { Plugin } from './registry.ts' +import type { Plugin } from './registry.ts' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' -import { Impl } from './reflect.ts' -import { StandardSchemaV1 } from '@standard-schema/spec' +import type { Impl } from './reflect.ts' +import type { StandardSchemaV1 } from '@standard-schema/spec' declare module './context.ts' { export interface Context extends Pick { diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index 3c5ad10525..ad266817bb 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,7 +1,7 @@ import { defineProperty, hyphenate } from 'cosmokit' import { Context } from './context.ts' import { Fiber } from './fiber.ts' -import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' +import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' declare module './context.ts' { interface Intercept { diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index e91cb63fea..63dd5b0cd2 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,4 +1,5 @@ -import { defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable } from 'cosmokit' +import type { Dict } from 'cosmokit' import { Context } from './context.ts' import { getTraceable, symbols, withProps } from './utils.ts' import { Fiber, FiberState } from './fiber.ts' diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index 05fbadcfad..d013e86081 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,5 +1,6 @@ -import { defineProperty, Dict } from 'cosmokit' -import { StandardSchemaV1 } from '@standard-schema/spec' +import { defineProperty } from 'cosmokit' +import type { Dict } from 'cosmokit' +import type { StandardSchemaV1 } from '@standard-schema/spec' import { Context } from './context.ts' import { Fiber } from './fiber.ts' import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts' diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index dc6622b68f..f58240368d 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,6 +1,6 @@ import { defineProperty } from 'cosmokit' import { Context } from './context.ts' -import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' +import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' /** * Base class for services that expose a named API on `ctx`. diff --git a/vendor/hmr/src/error.ts b/vendor/hmr/src/error.ts index a5f7b21702..80045c1765 100644 --- a/vendor/hmr/src/error.ts +++ b/vendor/hmr/src/error.ts @@ -1,5 +1,5 @@ import { Context } from 'cordis' -import { BuildFailure } from 'esbuild' +import type { BuildFailure } from 'esbuild' import { codeFrameColumns } from '@babel/code-frame' import { readFileSync } from 'node:fs' diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index ada10cc934..9727580efd 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,8 +1,8 @@ -import { Context, Inject, Plugin, Service } from 'cordis' -import { Dict } from 'cosmokit' -import { ModuleJob, ModuleLoader, ResolveResult } from '@cordisjs/plugin-loader' +import { Context, Inject, Service, type Plugin } from 'cordis' +import type { Dict } from 'cosmokit' +import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' -import { ChokidarOptions, FSWatcher, watch } from 'chokidar' +import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { relative, resolve } from 'node:path' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index b1517d5458..2ca8fb41ad 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryOptions, EntryTree, isJsExpr } from '@cordisjs/plugin-loader' +import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' import { Context, Service } from 'cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index f6ce0fe306..a73e4dea0f 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,5 +1,5 @@ import { Context, Service } from 'cordis' -import { Entry, EntryOptions } from './entry.ts' +import { Entry, type EntryOptions } from './entry.ts' import { EntryTree } from './tree.ts' /** Runtime owner for a list of child loader entries. */ diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index a2e930c4fb..2361b41aaa 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,5 +1,5 @@ import { Context } from 'cordis' -import { Dict } from 'cosmokit' +import type { Dict } from 'cosmokit' import { Entry } from './entry.ts' declare module './entry.ts' { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 6855884e11..79db440601 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,6 +1,6 @@ import { composeError, Context } from 'cordis' -import { Dict, isNonNullable } from 'cosmokit' -import { Entry, EntryOptions } from './entry.ts' +import { isNonNullable, type Dict } from 'cosmokit' +import { Entry, type EntryOptions } from './entry.ts' import { EntryGroup } from './group.ts' /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index e18fc2ffa2..1e963ea073 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,7 +1,7 @@ import { Context, Inject, Service } from 'cordis' -import { defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable, type Dict } from 'cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, EntryOptions } from './config/entry.ts' +import { Entry, type EntryOptions } from './config/entry.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' diff --git a/vendor/loader/src/internal.ts b/vendor/loader/src/internal.ts index 083e45475f..38d6f589f5 100644 --- a/vendor/loader/src/internal.ts +++ b/vendor/loader/src/internal.ts @@ -1,5 +1,5 @@ -import { createRequire, LoadHookContext } from 'node:module' -import { Dict } from 'cosmokit' +import { createRequire, type LoadHookContext } from 'node:module' +import type { Dict } from 'cosmokit' /** Node internal module format names handled by loader hooks. */ export type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm' diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index ec5791f3af..8433f35ec8 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -3,6 +3,7 @@ "description": "Type driven schema validator", "version": "3.18.0", "private": true, + "type": "module", "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts index b57375d64b..5948797ae9 100644 --- a/vendor/schemastery/src/index.ts +++ b/vendor/schemastery/src/index.ts @@ -1,4 +1,4 @@ -import { Binary, clone, deepEqual, Dict, filterKeys, isNullable, isPlainObject, pick, valueMap } from 'cosmokit' +import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from 'cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' const kSchema = Symbol.for('schemastery') @@ -899,4 +899,4 @@ defineMethod('intersect', ['list'], ({ list }) => { defineMethod('transform', ['inner', 'callback', 'preserve'], ({ inner }, isInner) => inner!.toString(isInner)) -export = Schema +export default Schema diff --git a/vendor/schemastery/tsdown.config.ts b/vendor/schemastery/tsdown.config.ts index 57f2f5f6c4..3b3349847d 100644 --- a/vendor/schemastery/tsdown.config.ts +++ b/vendor/schemastery/tsdown.config.ts @@ -1,10 +1,9 @@ import { defineConfig } from 'tsdown' /** - * schemastery has no `"type": "module"` and publishes dual-format output - * (package.json: main → lib/index.cjs, module → lib/index.mjs). The entry is - * the JS emitted by tsc under lib/types; pin the bundled extensions - * explicitly because the defaults for a CommonJS package would emit .mjs/.js. + * Schemastery publishes dual-format output (package.json: main → lib/index.cjs, + * module → lib/index.mjs). The entry is the JS emitted by tsc under lib/types; + * pin the bundled extensions explicitly so the package retains both formats. */ export default defineConfig({ entry: ['lib/types/index.js'], From db3b12a0f71972bac1de188f96ea92a03c6ebb9c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:05:20 +0800 Subject: [PATCH 47/61] feat: launch dsh source with native TypeScript --- apps/cli/src/tsconfig-paths-loader.ts | 199 ++++++++++++++++++++++++++ bin/dsh | 15 +- package.json | 6 +- packages/ui/app-boot/src/index.ts | 6 +- scripts/demo-code-mode.mjs | 9 +- scripts/tspath-loader.ts | 14 ++ scripts/verify-cordis-config.ts | 67 +++++---- 7 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 apps/cli/src/tsconfig-paths-loader.ts create mode 100644 scripts/tspath-loader.ts diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts new file mode 100644 index 0000000000..e70f788f3d --- /dev/null +++ b/apps/cli/src/tsconfig-paths-loader.ts @@ -0,0 +1,199 @@ +/** + * Node module resolve hook for the `dsh` source launcher. It projects the root + * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax + * handling to Node's native transform-types runtime. + * @module @deepseek-ai/dsh/tsconfig-paths-loader + */ + +import { readFile, stat } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { ResolveHookContext, ResolveFnOutput } from 'node:module' +import ts from 'typescript' + +interface LoaderData { + tsconfigPath: string +} + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +interface PathRule { + pattern: string + prefix: string + suffix: string + targets: readonly string[] +} + +const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const + +/** Resolve package imports through one parsed tsconfig paths table. */ +export class TsconfigPathsResolver { + private readonly rules: readonly PathRule[] + private readonly configDirectory: string + private readonly manifests = new Map>() + + private constructor(tsconfigPath: string, paths: ts.MapLike) { + this.configDirectory = dirname(tsconfigPath) + this.rules = Object.entries(paths) + .map(([pattern, targets]) => { + const wildcard = pattern.indexOf('*') + return { + pattern, + prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard), + suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1), + targets, + } + }) + .sort((left, right) => { + const leftExact = left.pattern.includes('*') ? 0 : 1 + const rightExact = right.pattern.includes('*') ? 0 : 1 + return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length + }) + } + + /** + * Parse a tsconfig including its `extends` chain. + * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`. + * @returns A resolver backed by that path table. + */ + static create(tsconfigPath: string): TsconfigPathsResolver { + let unrecoverable: ts.Diagnostic | undefined + const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic }, + }) + if (parsed === undefined) { + const detail = unrecoverable === undefined + ? 'unknown configuration error' + : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n') + throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`) + } + const paths = parsed.options.paths + if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`) + return new TsconfigPathsResolver(tsconfigPath, paths) + } + + /** + * Resolve one bare package specifier to a source file when the importing + * package (or config-directory owner) declares that package at runtime. + * @param specifier Module specifier passed to Node. + * @param parentURL Importing file or Loader config-directory URL. + * @returns Source file URL, or `undefined` when normal Node resolution owns the request. + */ + async resolve(specifier: string, parentURL: string | undefined): Promise { + const packageName = packageNameFromSpecifier(specifier) + if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined + const matched = this.match(specifier) + if (matched === undefined) return undefined + const configParent = parentURL.endsWith('/') + const parentPath = fileURLToPath(parentURL) + const startDirectory = configParent ? parentPath : dirname(parentPath) + if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined + + for (const target of matched.targets) { + const substituted = target.replace('*', matched.wildcard) + const candidate = await existingSourcePath(resolve(this.configDirectory, substituted)) + if (candidate !== undefined) return pathToFileURL(candidate).href + } + return undefined + } + + private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined { + for (const rule of this.rules) { + if (!rule.pattern.includes('*')) { + if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' } + continue + } + if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue + const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length) + return { targets: rule.targets, wildcard } + } + return undefined + } + + private async isDeclaredRuntimeDependency( + startDirectory: string, + packageName: string, + searchAncestors: boolean, + ): Promise { + for (let directory = startDirectory; ; directory = dirname(directory)) { + const manifest = await this.readManifest(join(directory, 'package.json')) + if (manifest !== undefined) { + if (declaresRuntimeDependency(manifest, packageName)) return true + if (!searchAncestors) return false + } + const parent = dirname(directory) + if (parent === directory) return false + } + } + + private readManifest(path: string): Promise { + let pending = this.manifests.get(path) + if (pending !== undefined) return pending + pending = readFile(path, 'utf8').then( + content => JSON.parse(content) as PackageManifest, + (error: unknown) => { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + }, + ) + this.manifests.set(path, pending) + return pending + } +} + +let resolver: TsconfigPathsResolver | undefined + +/** Initialize the hook worker from the source-launch preloader. */ +export function initialize(data: LoaderData): void { + resolver = TsconfigPathsResolver.create(data.tsconfigPath) +} + +/** Resolve declared workspace packages to source and delegate every other request to Node. */ +export async function resolveHook( + specifier: string, + context: ResolveHookContext, + nextResolve: (specifier: string, context: ResolveHookContext) => Promise, +): Promise { + const url = await resolver?.resolve(specifier, context.parentURL) + return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true } +} + +// Node customization hooks discover this exact export name. +export { resolveHook as resolve } + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) { + return undefined + } + const segments = specifier.split('/') + return specifier.startsWith('@') + ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + : segments[0] || undefined +} + +function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean { + return manifest.name === packageName + || packageName in (manifest.dependencies ?? {}) + || packageName in (manifest.optionalDependencies ?? {}) + || packageName in (manifest.peerDependencies ?? {}) +} + +async function existingSourcePath(base: string): Promise { + const candidates = extname(base) === '' + ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))] + : [base] + for (const candidate of candidates) { + try { + if ((await stat(candidate)).isFile()) return candidate + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + return undefined +} diff --git a/bin/dsh b/bin/dsh index 040cfface2..319d915b18 100755 --- a/bin/dsh +++ b/bin/dsh @@ -1,7 +1,7 @@ #!/bin/sh -# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's -# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the -# current working tree — code changes apply on the next launch, no build step. +# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native +# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh) +# always executes the current working tree without a build step. set -eu # Resolve symlink chains without readlink -f (not on every macOS). @@ -15,7 +15,8 @@ while [ -L "$script" ]; do done root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) -# tsx is imported by absolute path because bare `--import tsx` resolves from -# the invoking cwd, which is usually outside this repository. -export TSX_TSCONFIG_PATH="$root/tsconfig.json" -exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@" +# The preloader projects this checkout's tsconfig paths into Node resolution; +# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook). +exec node --experimental-transform-types \ + --import "$root/scripts/tspath-loader.ts" \ + "$root/apps/cli/src/bin.ts" "$@" diff --git a/package.json b/package.json index a0fc4f780f..5134a597d8 100644 --- a/package.json +++ b/package.json @@ -95,13 +95,13 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "dsh": "node --import tsx apps/cli/src/bin.ts", + "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx apps/cli/src/bin.ts", + "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", + "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index df2d2b1ba4..4644912304 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process } /** - * After the tree settles, reject entries with no fiber, which indicates a - * swallowed module-import failure. Disabled entries are the only valid + * After the tree settles, reject entries with no fiber and name every plugin + * whose module failed to resolve. Disabled entries are the only valid * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. @@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) if (failed.length > 0) { const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`) + throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) } } diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 1118f10b96..d790fe15d5 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,14 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', [ + '--experimental-transform-types', + '--import', + './scripts/tspath-loader.ts', + 'apps/cli/src/bin.ts', + '--config', + 'examples/tui-agent/code-mode.cordis.yml', + ]], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/tspath-loader.ts b/scripts/tspath-loader.ts new file mode 100644 index 0000000000..adeecb6b64 --- /dev/null +++ b/scripts/tspath-loader.ts @@ -0,0 +1,14 @@ +/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */ + +import { register } from 'node:module' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined + ? fileURLToPath(new URL('../tsconfig.json', import.meta.url)) + : resolve(process.env.TSX_TSCONFIG_PATH) + +register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), { + parentURL: import.meta.url, + data: { tsconfigPath }, +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 6041be78ed..66d6e0f2a3 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,10 +1,10 @@ /** - * Validate Cordis Loader entry metadata and example package resolution. + * Validate Cordis Loader entry metadata and package resolution. * * The Loader interpolates only a plugin entry's `config`; expression objects in * fields such as `disabled` remain truthy data and silently change composition. - * Example configs run from built packages, so every named package must resolve - * from the examples workspace and every local package must be in the root + * Example configs and the dsh Web composition resolve named plugins from their + * owning workspace manifests. Local example packages must also be in the root * TypeScript project graph. */ @@ -42,7 +42,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType) const files = cordisConfigFiles(root) const errors: string[] = [] -const examplePluginReferences: PluginReference[] = [] +const pluginReferences: PluginReference[] = [] for (const file of files) { const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) @@ -56,9 +56,10 @@ for (const file of files) { } errors.push(...validateExampleResolution()) +errors.push(...validateAppResolution()) if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or example package resolution:') + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') for (const error of errors) console.error(`- ${error}`) process.exitCode = 1 } else { @@ -70,7 +71,7 @@ function validateEntry(value: unknown, file: string, path: string): void { errors.push(`${file}${path}: entry must be an object`) return } - recordExamplePlugin(value, file) + recordPlugin(value, file) validateMetadata(value, file, path) if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { @@ -84,7 +85,7 @@ function validateEntry(value: unknown, file: string, path: string): void { const patch = config.patches[index] const patchPath = `${path}.config.patches[${index}]` if (!isRecord(patch)) continue - recordExamplePlugin(patch, file) + recordPlugin(patch, file) validateMetadata(patch, file, patchPath) if (!isUnknownArray(patch.insert)) continue for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) { @@ -93,10 +94,8 @@ function validateEntry(value: unknown, file: string, path: string): void { } } -function recordExamplePlugin(entry: Record, file: string): void { - if (file.startsWith('examples/') && typeof entry.name === 'string') { - examplePluginReferences.push({ file, name: entry.name }) - } +function recordPlugin(entry: Record, file: string): void { + if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name }) } function validateExampleResolution(): string[] { @@ -105,25 +104,13 @@ function validateExampleResolution(): string[] { const dependencies = exampleManifest.dependencies ?? {} const localPackages = localPackageDirectories() const rootReferences = rootProjectReferences() - const requiredPackages = new Map>() - - for (const reference of examplePluginReferences) { - const packageName = packageNameFromSpecifier(reference.name) - if (packageName === undefined) continue - const locations = requiredPackages.get(packageName) ?? new Set() - locations.add(reference.file) - requiredPackages.set(packageName, locations) - } - - for (const [packageName, locations] of requiredPackages) { - if (!(packageName in dependencies)) { - violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`) - } - } + const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/')) + violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json')) + const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name))) const localExamplePackages = new Set([ ...Object.keys(dependencies), - ...requiredPackages.keys(), + ...[...requiredPackages].filter(packageName => packageName !== undefined), ]) for (const packageName of localExamplePackages) { const packageDirectory = localPackages.get(packageName) @@ -135,6 +122,30 @@ function validateExampleResolution(): string[] { return violations } +function validateAppResolution(): string[] { + const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml') + return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') +} + +function missingPluginDependencies( + references: readonly PluginReference[], + dependencies: Readonly>, + manifestPath: string, +): string[] { + const requiredPackages = new Map>() + for (const reference of references) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined) continue + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(reference.file) + requiredPackages.set(packageName, locations) + } + return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies + ? [] + : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) +} + function readManifest(path: string): PackageManifest { return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest } @@ -177,7 +188,7 @@ function rootProjectReferences(): Set { } function packageNameFromSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined const segments = specifier.split('/') if (specifier.startsWith('@')) { return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined From c804dfde3ee499026f51b43d311dd4c3b2d38dcf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:05:46 +0800 Subject: [PATCH 48/61] docs: document native TypeScript source launch --- ...-native-typescript-source-launch.i18n.yaml | 6 +++ ...-28-dsh-native-typescript-source-launch.md | 45 +++++++++++++++++++ ...-dsh-native-typescript-source-launch.zh.md | 45 +++++++++++++++++++ ...26-07-20-dsh-cli-personal-config.i18n.yaml | 6 +-- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 4 +- .../process/2026-06-16-pnpm-over-yarn.md | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 2 +- .../2026-06-17-ts-build-config.i18n.yaml | 6 +-- .../process/2026-06-17-ts-build-config.md | 4 +- .../process/2026-06-17-ts-build-config.zh.md | 4 +- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 6 +-- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + packages/ui/app-boot/README.i18n.yaml | 6 +-- packages/ui/app-boot/README.md | 10 ++--- packages/ui/app-boot/README.zh.md | 10 ++--- scripts/doc-budgets.manifest.json | 2 +- 20 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml new file mode 100644 index 0000000000..a96c124e92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +2026-07-28-dsh-native-typescript-source-launch.md: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b +2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md new file mode 100644 index 0000000000..019389f3e5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -0,0 +1,45 @@ +# Agent Note: Native TypeScript source launch for dsh + +Status: implemented + +English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md) + +## Problem + +The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch. + +Node's transform also does not perform type analysis. A type imported through an ordinary value import remains a runtime ESM request, and TypeScript's `export =` becomes a CommonJS assignment rather than an ESM default export. The source graph therefore has to use explicit type-only imports and native ESM exports; a resolve hook cannot repair incompatible source syntax. + +Cordis configuration introduces a separate resolution boundary. Bare plugins in `cordis.yml` do not pass through TypeScript import analysis, so their resolver manifest may omit the required dependencies. The Cordis Loader logs plugin import errors and leaves an entry without a fiber, but does not fail startup itself; a typo in the configuration can therefore produce an incomplete application with exit code 0. + +## Decision + +The `dsh` TUI, Web, and headless source launches use `node --experimental-transform-types`; Node performs TypeScript transformation without loading `tsx` or esbuild. `bin/dsh`, the root-level `dsh`/TUI/Web demos, and Code Mode TUI enter the same `apps/cli/src/bin.ts` launch chain. Test and E2E launchers retain their existing strategies, and the built `lib/bin.js` continues to run under ordinary Node. + +`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. + +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. + +`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. + +After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior. + +Node-compatible TypeScript is part of this source-launch contract. Vendored Cordis, Loader, Include, HMR, and Schemastery mark erased imports with `import type`. Schemastery uses a native ESM default export and declares `type: module`; its `.mjs` and `.cjs` build outputs retain the existing ESM-default and callable-`require()` behavior. These divergences are recorded in `vendor/README.md`; no runtime behavior is added to the vendored frameworks. + +## Alternatives considered + +**Continue using `tsx`.** Rejected because `tsx`/esbuild would continue to own TypeScript transformation, so this launch chain could not prove that Node's native transformation works. + +**Load the built `lib/` through package exports from the source entry point.** Rejected because this would mix the source plane with the artifact plane; a zero-build development launch could read stale artifacts or fail outright. + +**Apply the root tsconfig `paths` unconditionally.** Rejected because this would allow undeclared cross-package imports and Cordis plugins to keep resolving, hiding mismatches between the manifest and the actual runtime graph. + +**Transform imports inside the custom loader.** Rejected because type-aware source rewriting would reintroduce a compiler-style transform and make the loader, rather than Node, responsible for TypeScript execution. Making the checked-in source Node-compatible keeps the launch boundary explicit. + +## Consequences + +- TUI/headless retain a zero-build source loop, while Web still builds its frontend artifacts before starting the CLI source entry. TypeScript syntax passes only through Node's native transform; the URL-only loader uses the checkout's root development dependencies and adds no CLI runtime dependency. +- Workspace package imports and Cordis configuration dependencies must both be declared explicitly in the resolver manifest; the static gate prevents configuration from landing before its dependencies, while extra dependencies are not errors. +- Plugin import failures no longer leave an incomplete application with exit code 0; the final error identifies both the Cordis startup failure and the specific plugin names, while the Loader's original error remains earlier in the logs. +- Vendored source in the CLI graph must remain compatible with Node's transform-types module semantics; the local-modification log makes the upstream sync obligation explicit. +- CI's `lib` mode, test/E2E launchers, and other example launchers retain their existing strategies; this native source loader covers only the `dsh` CLI application chain. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md new file mode 100644 index 0000000000..2cfff25d22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -0,0 +1,45 @@ +# Agent Note: dsh 原生 TypeScript 源码启动 + +Status: implemented + +[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 + +## 问题 + +`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。 + +Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。 + +Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。 + +## 决策 + +`dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。 + +`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 + +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 + +`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 + +Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 + +Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR(热模块替换)和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs` 和 `.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。 + +## 曾考虑的替代方案 + +**继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。 + +**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;零构建开发启动可能读取陈旧产物或直接失败。 + +**无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。 + +**在自定义 loader 内转换 import。** 不采用,因为感知类型的源码改写会重新引入编译器式转换,并让 loader 而非 Node 负责执行 TypeScript。使签入仓库的源码兼容 Node,可以让启动边界保持显式。 + +## 后果 + +- TUI/无头界面保留零构建源码回路,Web 仍会在启动 CLI 源码入口前构建前端产物。TypeScript 语法只经过 Node 原生转换;仅处理 URL 的 loader 使用 checkout 根目录的开发依赖,不增加 CLI 运行时依赖。 +- workspace package import 和 Cordis 配置依赖都必须在解析方 manifest 中明确声明;静态门禁防止配置先于依赖落地,额外依赖不构成错误。 +- 插件 import 失败不再留下退出码为 0 的残缺应用;最终错误同时说明 Cordis 启动失败及具体插件名,Loader 的原始错误仍会保留在更早的日志中。 +- CLI 源码图中的 vendor 源码必须与 Node 的 transform-types 模块语义兼容;本地修改记录明确了上游同步义务。 +- CI 的 `lib` 模式、测试/e2e 启动器和其他示例启动器保留各自现有策略;该原生源码 loader 只覆盖 `dsh` CLI 应用链路。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 76dd488060..401d02aeac 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-dsh-cli-personal-config.md: 9525aa811d792a918f03a52c21bc273e92fb8be7 -2026-07-20-dsh-cli-personal-config.zh.md: f21d4b1f22b3a3807b6b4155969282343f6048f5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95 +2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 9525aa811d..cc96543821 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -12,7 +12,7 @@ A developer's own preferences — which provider and model the TUI uses, persona Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index f21d4b1f22..02f9c57806 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -12,7 +12,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index 754b592530..6703e7bc9d 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.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 .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md -2026-06-16-pnpm-over-yarn.md: 918da1d056dfec73f78d849e08ad06d397e654c0 -2026-06-16-pnpm-over-yarn.zh.md: 4fbb357f689b4a960d0662cbefa5bec30f79bcc1 +2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1 +2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index 918da1d056..30b34c65fd 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -8,7 +8,7 @@ English | [中文](2026-06-16-pnpm-over-yarn.zh.md) The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers. -The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); dev/test/demo all run **unbuilt** via tsx, so the package manager only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small. +The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); development, tests, and source-mode demos run through their declared TypeScript launchers, while artifact checks build explicitly. The package manager therefore only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small. ## Decision diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 4fbb357f68..eb13890b7e 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -8,7 +8,7 @@ Status: implemented 本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 -切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 +切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 45ddcdfdae..4319bcee3e 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3 -2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md +2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf +2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 5bdfc5e170..20dd3d8d0a 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -62,7 +62,7 @@ pnpm run clean: tsx scripts/clean.ts ``` -`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. +The source-mode demos run through their declared TypeScript launchers and the root paths map. The `dsh` TUI chain uses Node's native transform plus its app-owned paths loader, the Web demo builds its required artifacts before entering that same CLI source chain, and the other source demos continue to use tsx. ## Alternatives considered @@ -75,7 +75,7 @@ tsx scripts/clean.ts Build responsibilities are clearer: -- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. +- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. - The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 9f74fb6be9..196ed3f292 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -62,7 +62,7 @@ pnpm run clean: tsx scripts/clean.ts ``` -`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 +源码模式 demo 通过各自声明的 TypeScript 启动器和根路径映射运行。`dsh` TUI 链使用 Node 原生转换及应用自有的路径 loader,Web demo 在进入同一条 CLI 源码链路前先构建所需产物,其他源码 demo 继续使用 tsx。 ## 曾考虑的替代方案 @@ -75,7 +75,7 @@ tsx scripts/clean.ts 构建职责更加清晰: -- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `tsx` 和 `vitest`)。 +- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 - `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 diff --git a/AGENTS.md b/AGENTS.md index c987801c17..79d6fcf37d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..cbb578efc4 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: e250f3b4c3c2031935b9a6abc3c73c03cfb0eccd +README.zh.md: cf5d0e7f05025bf87e2a47decd265762353b7feb diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..e250f3b4c3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -26,4 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` +Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. It reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. The loader maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. + `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..cf5d0e7f05 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -26,4 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` +源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。设置 `TSX_TSCONFIG_PATH` 时,它会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 + `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 18051a278b..08a274fd25 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 59fc4ed46f047ee8f72aa237ec3a47b21091358f -README.zh.md: 596392a8eb11469a9f3afb34046c2c37521a4ed6 +# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md +README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad +README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 59fc4ed46f..0282d3e955 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -9,18 +9,18 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | -| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | +| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. +Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. -This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself. +This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. ## Personal config @@ -41,7 +41,7 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec ## Known Limitations and Deferred Work -- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping. +- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. - **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 596392a8eb..b7121bbd28 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -9,18 +9,18 @@ | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | -| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目(即导入失败的插件模块),则抛出异常 | +| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection。 +这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 -此包不包含 loader 钩子,也不提供开发模式接口:`dsh-scripts` launcher([`sdk/scripts`](../../sdk/scripts/README.md),共享项目模型见 [`sdk/helper`](../../sdk/helper/README.md))持有进程启动、tsx 注册和本地插件源代码解析,并在自身的启动序列中使用这些 helper。 +此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 ## 个人配置 @@ -41,7 +41,7 @@ ## 已知限制与延期工作 -- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或使用 tsx 路径映射。 +- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 - **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4cae854147..ad64728359 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1705, + "AGENTS.md": 1720, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, From e5218217c35cc254c3efc15354e544ef5a85dc88 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:20:18 +0800 Subject: [PATCH 49/61] chore: raise lint heap limit --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5134a597d8..24857b1e52 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js .", + "lint:fix": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", From b6b4a66da0d455842306ddf7eb462e8a09a1d09d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:31:28 +0800 Subject: [PATCH 50/61] pkg: add build:lib script --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 24857b1e52..afa74fdf1e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "website" ], "scripts": { - "build": "tsc -b && tsdown", + "build": "npm run build:lib && npm run build:web", + "build:lib": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", @@ -29,7 +30,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && npm run build:web && vitest run --config vitest.web.config.ts", + "test:web": "npm run build && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", @@ -101,7 +102,7 @@ "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", + "demo:web": "npm run build && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" From 431c2180c15b1aee0a668f50b5d8affa807d8a8e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:42:54 +0800 Subject: [PATCH 51/61] fix(cli): harden native source resolution --- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/tsconfig-paths-loader.ts | 31 +++- apps/cli/tests/tsconfig-paths-loader.spec.ts | 180 +++++++++++++++++++ scripts/doc-budgets.manifest.json | 2 +- 7 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 apps/cli/tests/tsconfig-paths-loader.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 79d6fcf37d..a89bb82bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbb578efc4..e9e5df7630 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: e250f3b4c3c2031935b9a6abc3c73c03cfb0eccd -README.zh.md: cf5d0e7f05025bf87e2a47decd265762353b7feb +README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9 +README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b diff --git a/apps/cli/README.md b/apps/cli/README.md index e250f3b4c3..13a80b1d0e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -26,6 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. It reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. The loader maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. +Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index cf5d0e7f05..2a5d9c15c5 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -26,6 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。设置 `TSX_TSCONFIG_PATH` 时,它会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 +源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts index e70f788f3d..b7337998bc 100644 --- a/apps/cli/src/tsconfig-paths-loader.ts +++ b/apps/cli/src/tsconfig-paths-loader.ts @@ -29,16 +29,29 @@ interface PathRule { targets: readonly string[] } +interface PathsCompilerOptions { + readonly baseUrl?: string + readonly paths?: ts.MapLike + readonly pathsBasePath?: string +} + +// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded. const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const -/** Resolve package imports through one parsed tsconfig paths table. */ +/** + * Resolve package imports through one parsed tsconfig paths table. + * + * Manifest reads are process-scoped and memoized by path. Only matched source + * aliases enter the cache, bounding it to directories participating in source + * resolution. + */ export class TsconfigPathsResolver { private readonly rules: readonly PathRule[] private readonly configDirectory: string private readonly manifests = new Map>() - private constructor(tsconfigPath: string, paths: ts.MapLike) { - this.configDirectory = dirname(tsconfigPath) + private constructor(configDirectory: string, paths: ts.MapLike) { + this.configDirectory = configDirectory this.rules = Object.entries(paths) .map(([pattern, targets]) => { const wildcard = pattern.indexOf('*') @@ -73,9 +86,11 @@ export class TsconfigPathsResolver { : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n') throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`) } - const paths = parsed.options.paths + const options = parsed.options as PathsCompilerOptions + const paths = options.paths if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`) - return new TsconfigPathsResolver(tsconfigPath, paths) + const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath) + return new TsconfigPathsResolver(configDirectory, paths) } /** @@ -168,7 +183,7 @@ export async function resolveHook( export { resolveHook as resolve } function packageNameFromSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) { + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) { return undefined } const segments = specifier.split('/') @@ -185,7 +200,9 @@ function declaresRuntimeDependency(manifest: PackageManifest, packageName: strin } async function existingSourcePath(base: string): Promise { - const candidates = extname(base) === '' + const extension = extname(base) + if (extension === '.tsx') return undefined + const candidates = extension === '' ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))] : [base] for (const candidate of candidates) { diff --git a/apps/cli/tests/tsconfig-paths-loader.spec.ts b/apps/cli/tests/tsconfig-paths-loader.spec.ts new file mode 100644 index 0000000000..834ee8c4d0 --- /dev/null +++ b/apps/cli/tests/tsconfig-paths-loader.spec.ts @@ -0,0 +1,180 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { ResolveFnOutput, ResolveHookContext } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts' + +class ResolverFixture { + readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-')) + + path(relativePath: string): string { + return join(this.root, relativePath) + } + + write(relativePath: string, content = 'export {}\n'): string { + const path = this.path(relativePath) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + return path + } + + writeJson(relativePath: string, value: unknown): string { + return this.write(relativePath, `${JSON.stringify(value)}\n`) + } + + createResolver(paths: Record): TsconfigPathsResolver { + const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } }) + return TsconfigPathsResolver.create(tsconfigPath) + } + + parentURL(relativePath = 'consumer/src/nested/index.ts'): string { + return pathToFileURL(this.path(relativePath)).href + } + + dispose(): void { + rmSync(this.root, { recursive: true, force: true }) + } +} + +const fixtures: ResolverFixture[] = [] + +function fixture(): ResolverFixture { + const value = new ResolverFixture() + fixtures.push(value) + return value +} + +afterEach(() => { + for (const value of fixtures.splice(0)) value.dispose() +}) + +describe('TsconfigPathsResolver', () => { + it('orders exact, longer-prefix, and longer-suffix path rules', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { + dependencies: { + '@scope/feature-name': '*', + '@scope/feature-other': '*', + '@scope/plain-suffix': '*', + }, + }) + files.write('targets/exact.ts') + files.write('targets/prefix/other.ts') + files.write('targets/generic/feature-other.ts') + files.write('targets/suffix/plain.ts') + files.write('targets/generic/plain-suffix.ts') + const resolver = files.createResolver({ + '@scope/*': ['./targets/generic/*'], + '@scope/*-suffix': ['./targets/suffix/*'], + '@scope/feature-*': ['./targets/prefix/*'], + '@scope/feature-name': ['./targets/exact.ts'], + }) + + await expect(resolver.resolve('@scope/feature-name', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href) + await expect(resolver.resolve('@scope/feature-other', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href) + await expect(resolver.resolve('@scope/plain-suffix', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href) + }) + + it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { + name: 'self-package', + dependencies: { dependency: '*' }, + optionalDependencies: { optional: '*' }, + peerDependencies: { peer: '*' }, + }) + for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) { + files.write(`targets/${name}.ts`) + } + const resolver = files.createResolver(Object.fromEntries( + ['self-package', 'dependency', 'optional', 'peer', 'undeclared'] + .map(name => [name, [`./targets/${name}`]]), + )) + + for (const name of ['self-package', 'dependency', 'optional', 'peer']) { + await expect(resolver.resolve(name, files.parentURL())) + .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href) + } + await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined() + }) + + it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => { + const files = fixture() + const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing'] + files.writeJson('consumer/package.json', { + dependencies: Object.fromEntries(names.map(name => [name, '*'])), + }) + files.write('targets/plain.ts') + files.write('targets/module.mts') + files.write('targets/common.cts') + files.write('targets/directory/index.ts') + files.write('targets/component.tsx') + const resolver = files.createResolver({ + 'plain-ts': ['./targets/plain'], + 'module-mts': ['./targets/module'], + 'common-cts': ['./targets/common'], + 'directory': ['./targets/directory'], + 'tsx-implicit': ['./targets/component'], + 'tsx-explicit': ['./targets/component.tsx'], + 'missing': ['./targets/missing'], + }) + + for (const [name, target] of [ + ['plain-ts', 'targets/plain.ts'], + ['module-mts', 'targets/module.mts'], + ['common-cts', 'targets/common.cts'], + ['directory', 'targets/directory/index.ts'], + ] as const) { + await expect(resolver.resolve(name, files.parentURL())) + .resolves.toBe(pathToFileURL(files.path(target)).href) + } + await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined() + await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined() + await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined() + }) + + it('anchors inherited paths at the config that declared them', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { dependencies: { custom: '*' } }) + files.write('targets/custom.ts') + files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } }) + const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' }) + const resolver = TsconfigPathsResolver.create(customTsconfig) + + await expect(resolver.resolve('custom', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href) + }) + + it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { dependencies: { matched: '*' } }) + const target = files.write('targets/matched.ts') + const tsconfigPath = files.writeJson('tsconfig.json', { + compilerOptions: { paths: { matched: ['./targets/matched'] } }, + }) + initialize({ tsconfigPath }) + const context: ResolveHookContext = { + conditions: [], + importAttributes: {}, + parentURL: files.parentURL(), + } + const nextResolve = vi.fn(async ( + specifier: string, + _context: ResolveHookContext, + ): Promise => ({ url: `next:${specifier}` })) + + await expect(resolveHook('matched', context, nextResolve)) + .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true }) + expect(nextResolve).not.toHaveBeenCalled() + + for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) { + await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` }) + expect(nextResolve).toHaveBeenLastCalledWith(specifier, context) + } + }) +}) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index ad64728359..017c341a34 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1720, + "AGENTS.md": 1750, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, From 0efc7f045ec80e04a22f6e952d076b2879fa37cb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:03:56 +0800 Subject: [PATCH 52/61] refactor(client): carve outward interfaces for the session, workspace, layout, slash, and conversation services Feature packages now reach these domains through interface types only: ISession/SessionFace (identity + prompt/cancel/loadOlder + the useSession snapshot source), ISessions, IWorkspaces, ILayout, IConversation, and the existing SlashServiceContract now actually mounted on Context.slash. The concrete services implement their face; wire-pump and assembly entry points stay on the classes. The provide-channel materialization and current-projection logic moves into SessionProvideChannel so the production service and the client test runtime share one implementation. The workspaces service consumes sessions through the narrow SessionsPort. --- .../runtime/src/client/contract/session.ts | 56 ++++++ .../src/client/contract/sessions-port.ts | 47 +++++ .../runtime/src/client/contract/sessions.ts | 64 ++++++ .../runtime/src/client/contract/workspaces.ts | 65 ++++++ packages/client/runtime/src/client/index.ts | 13 +- .../runtime/src/client/sessions/provide.ts | 190 ++++++++++++++++++ .../runtime/src/client/sessions/service.ts | 158 +++------------ .../runtime/src/client/sessions/session.ts | 8 +- .../runtime/src/client/workspaces/service.ts | 11 +- .../client/ui-command/src/client/service.ts | 8 +- .../ui-conversation/src/client/apply.ts | 7 +- .../ui-conversation/src/client/index.ts | 6 +- .../ui-conversation/src/client/input/hub.ts | 11 +- .../ui-conversation/src/client/queue/store.ts | 6 +- .../ui-conversation/src/client/service.ts | 55 +++-- packages/client/ui-layout/src/client/index.ts | 6 +- .../client/ui-layout/src/client/service.ts | 17 +- packages/client/ui-slash/src/client/index.ts | 3 +- .../client/ui-slash/src/client/service.ts | 4 +- 19 files changed, 556 insertions(+), 179 deletions(-) create mode 100644 packages/client/runtime/src/client/contract/session.ts create mode 100644 packages/client/runtime/src/client/contract/sessions-port.ts create mode 100644 packages/client/runtime/src/client/contract/sessions.ts create mode 100644 packages/client/runtime/src/client/contract/workspaces.ts create mode 100644 packages/client/runtime/src/client/sessions/provide.ts diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts new file mode 100644 index 0000000000..2ada5e6fbc --- /dev/null +++ b/packages/client/runtime/src/client/contract/session.ts @@ -0,0 +1,56 @@ +/** + * The outward session face. Feature packages never see the concrete Session + * class: components read conversation state through `useSession` (the + * ObservableSnapshot half), and orchestration code calls the behavior verbs + * below — nothing else. Widening this interface is the explicit act of + * widening what features may do to a session (and what every test fixture + * must stub); runtime-internal entry points (history staging, wire-frame + * dispatch) stay on the class, invisible out here. + */ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ConversationSnapshot } from '../sessions/conversation.ts' +import type { ObservableSnapshot } from './store.ts' + +/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ +export interface ProjectionsFace { + /** + * The identity-stable bare observable for one projection key (absence is + * an `undefined` snapshot, never a missing face). + * @param key - projection key. + * @returns the key's value face. + */ + faceOf(key: string): ObservableSnapshot +} + +/** Identity plus the behavior verbs features may invoke on a session. */ +export interface ISession { + /** The session's host identity (agent id — same axis). */ + readonly sessionId: SessionId + /** Host-computed projection values by key (the useProjection seat). */ + readonly projections: ProjectionsFace + /** + * Send a prompt into the session. + * @param content - model-facing content blocks. + * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @returns acceptance, or the business error (also mirrored into snapshot.promptError). + */ + prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> + /** + * Cancel the running turn. + * @returns acceptance, or the business error. + */ + cancel(): Promise> + /** + * Extend the history window backwards (older messages pagination). + * @returns completion; failures land in snapshot.openState/loadingOlder. + */ + loadOlder(): Promise +} + +/** + * The full outward face: behavior verbs plus the conversation read side + * (the `useSession` hook source). This is the type carried by + * `SessionBinding.session` and the provide channel. + */ +export type SessionFace = ISession & ObservableSnapshot diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts new file mode 100644 index 0000000000..466e26fe12 --- /dev/null +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -0,0 +1,47 @@ +/** + * Cross-domain sessions face: the contract surface sibling domains (today: + * workspaces) consume instead of the sessions implementation. The sessions + * domain satisfies it structurally — SessionsService is assignable, checked + * wherever the assembly layer or a test injects the real service — so + * widening this face is the explicit act of widening the inter-domain + * dependency. + */ + +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { ObservableSnapshot } from './store.ts' + +/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */ +export interface SessionsPortSummary { + id: SessionId + /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */ + blank: boolean + cwd?: string + updatedAt: number +} + +/** Session-list facts sibling domains read: readiness, selection, and the row map. */ +export interface SessionsPortList { + ids: SessionId[] + byId: Record + current: SessionId | undefined + phase: 'pending' | 'ready' +} + +/** The sessions-service face injected into sibling domains. */ +export interface SessionsPort { + /** Observable list snapshot (read face only; writes stay inside the sessions domain). */ + readonly list: ObservableSnapshot + /** + * Create a session on the host. + * @param opts - target workspace. + * @returns the new session id. + */ + create(opts: { workspaceId: WorkspaceId }): Promise + /** + * Select a session as current. + * @param id - session id (must exist in the list store). + */ + open(id: SessionId): void + /** Clear the current selection into the no-session view state. */ + clear(): void +} diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts new file mode 100644 index 0000000000..d26b392072 --- /dev/null +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -0,0 +1,64 @@ +/** + * The outward sessions-service face — what `ctx.sessions` exposes to feature + * packages and the renderer host, and therefore exactly what the test + * runtime's sessions double must implement. Wire-pump entry points + * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on + * the concrete class; cross-domain consumers keep the narrower + * [SessionsPort](./sessions-port.ts). Widening this interface is the + * explicit act of widening what features may do to the sessions domain. + */ +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { + SessionBinding, SessionListState, SessionProvideDescriptor, +} from '../sessions/service.ts' +import type { SessionFace } from './session.ts' +import type { ObservableSnapshot } from './store.ts' + +/** The sessions-service face injected as `ctx.sessions`. */ +export interface ISessions { + /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ + readonly list: ObservableSnapshot + /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ + readonly currentProvideInfo: HostObservable + /** + * Select a session as current. + * @param id - session id (must exist in the list; unknown ids fail loud). + */ + open(id: SessionId): void + /** Clear the current selection into the no-session view state. */ + clear(): void + /** + * Register a per-session standard-props provider (hooks become `use` + * selector hooks on the render side; props spread verbatim). + * @param descriptor - static member roster plus per-session resolver. + * @returns disposer removing the provider. + */ + provide(descriptor: SessionProvideDescriptor): () => void + /** + * Resolve an Agent-scoped context view (use-and-discard). + * @param id - session id. + * @returns scoped ctx, or undefined for a session neither listed nor already scoped. + */ + scope(id: SessionId): Context | undefined + /** + * Read the Agent scope tag off a context (service-method seam: fetch + * bundles must reach scope resolution through ctx.sessions). + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined + /** + * Resolve the session face behind an Agent-scoped context. + * @param ctx - an Agent-scoped context. + * @returns the session face, or undefined when the ctx is untagged or its scope was pruned. + */ + sessionOf(ctx: Context): SessionFace | undefined + /** + * Resolve the stable session binding (scope-addressed assembly feed). + * @param id - session id. + * @returns binding, or undefined for a session neither listed nor already scoped. + */ + binding(id: SessionId): SessionBinding | undefined +} diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts new file mode 100644 index 0000000000..cd32cbf56f --- /dev/null +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -0,0 +1,65 @@ +/** + * The outward workspaces-service face — what `ctx.workspaces` exposes to + * feature packages and the renderer host, and therefore exactly what the + * test runtime's workspaces double must implement. Wire-pump entry points + * (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on + * the concrete class. Widening this interface is the explicit act of + * widening what features may do to the workspaces domain. + */ +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { WorkspaceListState } from '../workspaces/service.ts' +import type { ObservableSnapshot } from './store.ts' + +/** The workspaces-service face injected as `ctx.workspaces`. */ +export interface IWorkspaces { + /** The useWorkspaces standard feed (read face — writes stay inside the domain). */ + readonly list: ObservableSnapshot + /** + * Connect a Workspace to its reusable or freshly created blank session. + * @param workspaceId - target workspace. + * @returns the connected session id. + */ + connectWorkspace(workspaceId: WorkspaceId): Promise + /** + * The New Session flow: connect the target (or recent) Workspace and open + * the resulting session; failures surface on the session list state. + * @param workspaceId - explicit target; omitted uses the recency projection. + */ + startSession(workspaceId?: WorkspaceId): void + /** + * Create a Workspace by name or register an existing path. + * @param input - exactly one Host create spelling. + * @returns the created or idempotently resolved Workspace. + */ + create(input: { name: string } | { path: string }): Promise + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + pickDirectory(): Promise + /** + * Open a filesystem path with the Host operating system's default application. + * @param path - absolute or host-resolvable path. + */ + openPath(path: string): Promise + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - the new display title. + * @returns the updated Workspace view. + */ + rename(workspaceId: WorkspaceId, title: string): Promise + /** + * Delete a Workspace (its sessions fall back to the unaccounted group). + * @param workspaceId - target workspace. + */ + delete(workspaceId: WorkspaceId): Promise + /** + * Move an accounted session within/into a Workspace's ordered list. + * @param workspaceId - target workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the updated Workspace view. + */ + insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a7e9104b6c..fb4985fef4 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts' export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +// The provide channel is shared with the client test runtime (one +// materialization/projection implementation; no test-side mirror to drift). +export { SessionProvideChannel } from './sessions/provide.ts' +export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' +export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { ISessions } from './contract/sessions.ts' +export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' @@ -109,8 +116,10 @@ declare module 'cordis' { } interface Context { slots: import('./slots.ts').SlotsService - sessions: import('./sessions/service.ts').SessionsService - workspaces: import('./workspaces/service.ts').WorkspacesService + /** The outward face only; the concrete service stays inside the runtime. */ + sessions: import('./contract/sessions.ts').ISessions + /** The outward face only; the concrete service stays inside the runtime. */ + workspaces: import('./contract/workspaces.ts').IWorkspaces } } diff --git a/packages/client/runtime/src/client/sessions/provide.ts b/packages/client/runtime/src/client/sessions/provide.ts new file mode 100644 index 0000000000..8039d7faf7 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/provide.ts @@ -0,0 +1,190 @@ +/** + * The session standard-props provide channel: provider roster, bundle + * materialization (fail-loud on undeclared/missing/duplicate members), the + * static no-session projection, and the atomic current-session projection + * observable. One implementation — SessionsService drives it from wire + * truth, the test runtime's sessions double drives it from fixtures — so + * the materialization rules and the projection semantics cannot drift + * between production and the test bench. + */ +import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionBinding, SessionProvideDescriptor } from './service.ts' + +/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */ +export interface SessionProvideChannelHost { + /** + * Re-materialize every already-materialized bundle against the new roster + * (call {@link SessionProvideChannel.materializeInfo} per live binding). + * Lazily-materialized sessions pick the new roster up on first resolve. + */ + rebuildBundles(): void + /** Resolve the current selection's bundle (the owner's maybe-provide lookup). */ + resolveCurrent(): SessionMaybeProvideInfo +} + +/** + * Provider roster + materialization + current projection. The channel owns + * every rule a provider contribution must satisfy; owners keep only their + * per-session bundle storage and the definition of "current". + */ +export class SessionProvideChannel { + private readonly providers: SessionProvideDescriptor[] = [] + private maybeInfoCache: SessionMaybeProvideInfo + /** Latest published current bundle (identity comparison dedupes republish). */ + private currentSnapshot: SessionMaybeProvideInfo + /** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */ + private readonly listeners = new Set<() => void>() + + /** + * Atomic current-session provide projection: selection changes and + * provider-roster changes publish through this one source, so a roster + * change under a stable current id republishes the bundle instead of + * stranding mounted entries. + */ + readonly currentProvideInfo: HostObservable + + /** + * @param host - owner-side bundle storage and current-selection resolution. + */ + constructor(private readonly host: SessionProvideChannelHost) { + // The runtime's own contribution comes first: useSession rides the same + // provide channel every plugin uses (no renderer special case). + this.providers.push({ + hooks: ['session'], + resolve: binding => ({ hooks: { session: binding.session } }), + }) + this.maybeInfoCache = this.materializeMaybeInfo() + this.currentSnapshot = this.maybeInfoCache + this.currentProvideInfo = { + getSnapshot: () => this.currentSnapshot, + subscribe: (fn) => { + this.listeners.add(fn) + return () => { this.listeners.delete(fn) } + }, + } + } + + /** The static no-session projection under the current roster (declared names present, values undefined). */ + get maybeInfo(): SessionMaybeProvideInfo { + return this.maybeInfoCache + } + + /** + * Register a per-session standard-props provider (see + * SessionsService.provide for the product contract). Live bundles rebuild + * immediately; misdeclared providers fail loud here, at the registration + * edge, and the registration rolls back — the channel never stays on a + * roster it cannot materialize. + * @param descriptor - static member roster plus per-session resolver. + * @returns disposer removing the provider. + */ + provide(descriptor: SessionProvideDescriptor): () => void { + this.providers.push(descriptor) + try { + this.applyRosterChange() + } catch (error) { + this.providers.splice(this.providers.indexOf(descriptor), 1) + // Restore the previous (valid) roster's bundles; cannot rethrow — the + // pre-push roster materialized successfully before. + this.applyRosterChange() + throw error + } + return () => { + const at = this.providers.indexOf(descriptor) + if (at >= 0) this.providers.splice(at, 1) + this.applyRosterChange() + } + } + + /** + * Re-derive the current selection's bundle and publish it when it changed. + * Bundles are identity-stable per (scope, roster) materialization, so an + * identity compare is exact; synchronous notify — call sites (the owner's + * list subscription, provide()) already sit behind their own batching or + * registration edges. + */ + publishCurrent(): void { + const next = this.host.resolveCurrent() + if (next === this.currentSnapshot) return + this.currentSnapshot = next + for (const fn of [...this.listeners]) { + try { + fn() + } catch (error) { + // Contain subscriber failures: this notify runs inside the list + // notification, where a throwing render-side subscriber would starve + // later listeners and abort the projection pass that scheduled it. + console.error('sessions.currentProvideInfo subscriber failed:', error) + } + } + } + + /** + * Materialize the standard-props bundle for one session (fails loud on + * undeclared, missing, and duplicate member names). + * @param binding - session assembly handle fed to every resolver. + * @returns the materialized bundle (identity-stable until the next materialization). + */ + materializeInfo(binding: SessionBinding): SessionProvideInfo { + const hooks: Record> = {} + const props: Record = {} + for (const descriptor of this.providers) { + const contribution = descriptor.resolve(binding) + const contributedHooks = contribution.hooks ?? {} + const contributedProps = contribution.props ?? {} + for (const name of Object.keys(contributedHooks)) { + if (!(descriptor.hooks ?? []).includes(name)) { + throw new Error(`sessions.provide: undeclared hook "${name}"`) + } + } + for (const name of Object.keys(contributedProps)) { + if (!(descriptor.props ?? []).includes(name)) { + throw new Error(`sessions.provide: undeclared prop "${name}"`) + } + } + for (const name of descriptor.hooks ?? []) { + const source = contributedHooks[name] + if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) + if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) + hooks[name] = source + } + for (const name of descriptor.props ?? []) { + if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) + if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) + props[name] = contributedProps[name] + } + } + return { + sessionId: binding.sessionId, + hooks, + props, + // The useProjection seat: key-addressed bare value faces off the + // session's projection store (open key space — never a static roster member). + projections: { faceOf: key => binding.session.projections.faceOf(key) }, + } + } + + /** Rebuild the static projection and the owner's live bundles, then republish the current one. */ + private applyRosterChange(): void { + this.maybeInfoCache = this.materializeMaybeInfo() + this.host.rebuildBundles() + this.publishCurrent() + } + + /** Build the static no-session kit and reject duplicate declared names. */ + private materializeMaybeInfo(): SessionMaybeProvideInfo { + const hooks: Record = {} + const props: Record = {} + for (const descriptor of this.providers) { + for (const name of descriptor.hooks ?? []) { + if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) + hooks[name] = undefined + } + for (const name of descriptor.props ?? []) { + if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) + props[name] = undefined + } + } + return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session + } +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 19e022d450..64680587e2 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -22,9 +22,12 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' +import type { SessionFace } from '../contract/session.ts' +import type { ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase } from './manager.ts' +import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ @@ -79,7 +82,8 @@ export class SessionCreateError extends Error { /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ export interface SessionBinding { readonly sessionId: SessionId - readonly session: Session + /** The outward session face only — feature code never sees the concrete class. */ + readonly session: SessionFace readonly ctx: Context } @@ -119,6 +123,8 @@ interface ScopeRecord { fiber: Fiber ctx: Context binding: SessionBinding + /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ + session: Session /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ provideInfo: SessionProvideInfo } @@ -146,7 +152,7 @@ export interface SessionProvideDescriptor { } /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { +export class SessionsService implements ISessions { /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ @@ -170,14 +176,8 @@ export class SessionsService { private readonly selection: SnapshotStore<{ sessionId?: SessionId }> private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */ - private currentProvideInfoSnapshot: SessionMaybeProvideInfo - /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ - private readonly currentProvideInfoListeners = new Set<() => void>() + /** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */ + private readonly provideChannel: SessionProvideChannel /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -212,23 +212,17 @@ export class SessionsService { // The current-provide projection follows the same current writes. this.list.subscribe(() => { this.followCurrent() - this.updateCurrentProvideInfo() + this.provideChannel.publishCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - this.currentProvideInfoSnapshot = this.maybeInfo - this.currentProvideInfo = { - getSnapshot: () => this.currentProvideInfoSnapshot, - subscribe: (fn) => { - this.currentProvideInfoListeners.add(fn) - return () => { this.currentProvideInfoListeners.delete(fn) } + this.provideChannel = new SessionProvideChannel({ + rebuildBundles: () => { + for (const record of this.scopes.values()) { + record.provideInfo = this.provideChannel.materializeInfo(record.binding) + } }, - } + resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), + }) + this.currentProvideInfo = this.provideChannel.currentProvideInfo rootCtx.reflect.provide('sessions', this, undefined) } @@ -243,105 +237,10 @@ export class SessionsService { * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). */ provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - this.updateCurrentProvideInfo() - } - - /** - * Re-derive the current selection's provide bundle and publish it when it - * changed. Bundles are identity-stable per (scope, roster) - * materialization, so an identity compare is exact; synchronous notify — - * both call sites (list.subscribe, provide()) already sit behind their own - * batching or registration edges. - */ - private updateCurrentProvideInfo(): void { - const next = this.maybeProvideInfo(this.list.getSnapshot().current) - if (next === this.currentProvideInfoSnapshot) return - this.currentProvideInfoSnapshot = next - for (const fn of [...this.currentProvideInfoListeners]) { - try { - fn() - } catch (error) { - // Contain subscriber failures: this notify runs inside the list - // notification, where a throwing render-side subscriber would starve - // later listeners and abort the projection pass that scheduled it. - console.error('sessions.currentProvideInfo subscriber failed:', error) - } - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { - sessionId: binding.sessionId, - hooks, - props, - // The useProjection seat: key-addressed bare value faces off the - // session's projection store (open key space — never a static roster member). - projections: { faceOf: key => binding.session.projections.faceOf(key) }, - } + // scopes before later plugins register) — the channel rebuilds their + // bundles through the host hooks so every provider lands by first render. + return this.provideChannel.provide(descriptor) } /** @@ -439,9 +338,9 @@ export class SessionsService { * `agent.session`). Same service-method seam as * {@link SessionsService.scopeOf}. * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. + * @returns the session face, or undefined when the ctx is untagged or its scope was pruned. */ - sessionOf(ctx: Context): Session | undefined { + sessionOf(ctx: Context): SessionFace | undefined { const id = scopeTagOf(ctx) if (id === undefined) return undefined return this.scopes.get(id)?.binding.session @@ -473,7 +372,7 @@ export class SessionsService { * return the static no-session projection rather than removing hook props. */ private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo + return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo } /** @@ -497,7 +396,7 @@ export class SessionsService { * validates and the projection masks absent selections), so resolve * cannot miss; kept so a future current writer cannot crash the notify. */ if (record !== undefined) { - void record.binding.session.open() + void record.session.open() } } @@ -540,8 +439,9 @@ export class SessionsService { fiber, ctx, binding, + session, // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), + provideInfo: this.provideChannel.materializeInfo(binding), } this.scopes.set(id, record) return record @@ -608,7 +508,7 @@ export class SessionsService { void record.fiber.dispose() // Release the Session's dispatch point with the scope it belongs to (a // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() + record.session.unbindScope() // Optional lookup: slots and sessions are sibling services with no // declared dependency; a slots-less boot (object-layer tests) skips. this.rootCtx.get('slots')?.pruneStoreScope(id) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 00398d53af..8947e97f7f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -10,7 +10,7 @@ import type { // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ObservableSnapshot } from '../contract/store.ts' +import type { SessionFace } from '../contract/session.ts' import type { CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PromptError, QueuedMessage, RunningToolCall, @@ -67,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string { /** * Owns a session's event window, derived conversation state, and observable - * snapshot. React bindings remain outside this data layer. + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are manager/runtime entry points. */ -export class Session implements ObservableSnapshot { +export class Session implements SessionFace { // ---- Window and derived state (all private; the snapshot is the only read surface) ---- private events: SessionEvent[] = [] /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 97f01d0bf1..c69661b130 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -6,7 +6,8 @@ import type { } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' -import type { SessionsService } from '../sessions/service.ts' +import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' +import type { IWorkspaces } from '../contract/workspaces.ts' import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' /** Workspace list plus the two-baseline readiness and default-target projection. */ @@ -30,7 +31,7 @@ export class WorkspaceCreateError extends Error { } /** Real Workspace object layer and Host actions. */ -export class WorkspacesService { +export class WorkspacesService implements IWorkspaces { /** UI-facing immutable projection; the manager remains wire truth. */ readonly list: SnapshotStore /** Workspace baseline and frame owner. */ @@ -43,9 +44,9 @@ export class WorkspacesService { /** * @param ctx - client root context. * @param api - shared wire client. - * @param sessions - lower-level Session service used for recency and blank-session reuse. + * @param sessions - cross-domain sessions face used for recency and blank-session reuse. */ - constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) { + constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ items: [], state: 'idle', phase: 'pending', error: null, @@ -271,7 +272,7 @@ export class WorkspacesService { /** Stable tie-breaking follows Host Workspace order. */ function recentWorkspace( workspaces: readonly WorkspaceView[], - sessions: ReturnType['byId'], + sessions: SessionsPortList['byId'], ): WorkspaceId | undefined { let selected: WorkspaceId | undefined let selectedTime = Number.NEGATIVE_INFINITY diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 22f4a911b3..8f9f96731f 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -9,10 +9,10 @@ import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, - SlashServiceContract, SubmitOutcome, + SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandContribution, CommandServiceContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -46,7 +46,7 @@ export class CommandService extends Service implements CommandServiceContract { if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`) return result.value.commands }) - const slash = ctx.get('slash') as SlashServiceContract | undefined + const slash = ctx.get('slash') if (slash === undefined) throw new Error('ui-command: slash service unavailable') ctx.effect(() => slash.registerSource({ trigger: '/', @@ -292,7 +292,7 @@ export class CommandService extends Service implements CommandServiceContract { return this.sessions().scope(id) } - private sessions(): SessionsService { + private sessions(): ISessions { const sessions = this.ctx.get('sessions') if (sessions === undefined) throw new Error('ui-command: sessions service unavailable') return sessions diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 68df3a6da0..46c15d1d05 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { @@ -10,6 +10,7 @@ import type { import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' +import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' @@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx' /** Services required by the conversation plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces'] -/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ -function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService { +/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ +function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`) const conversation = scoped.get('conversation') diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 1b85c52abb..56d398def3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -3,10 +3,9 @@ * between the independently implemented skeleton and chat domains; `apply.ts` * owns their slot assembly. */ -import type { ConversationService } from './service.ts' - export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' +export type { IConversation } from './service.ts' export type { CallId, ChatStoreState, SelectionTarget, ViewTab, @@ -22,6 +21,7 @@ export type { declare module 'cordis' { interface Context { - conversation: ConversationService + /** The outward face only; the concrete service stays inside this plugin. */ + conversation: import('./service.ts').IConversation } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2ae474be31..2641e0dcc4 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -8,9 +8,8 @@ * bail events) and owns the default-sink choreography: every session is a * real host entity, so the sink is one unconditional prompt path. */ -import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client' -import type {} from '@deepseek-ai/dsh-client-ui-slash/client' +import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { PopupDismissFace } from './facade.ts' @@ -113,7 +112,7 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: Session, text: string, mode: 'queue' | 'steer'): void { + private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void { if (text === '') return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. @@ -129,7 +128,7 @@ export class InputHub implements InputService { } private controller(actx: ClientContext): SlashController | undefined { - const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined + const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) } @@ -138,7 +137,7 @@ export class InputHub implements InputService { return command?.popupFor(actx) } - private sessions(): SessionsService { + private sessions(): ISessions { const sessions = this.rootCtx.get('sessions') if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable') return sessions diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 5d113b750d..6f084ea39d 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -5,7 +5,7 @@ * reference-stable across unrelated snapshot swaps, so this is a pure * projection — no second store, no copy. */ -import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client' +import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { QueuedMessage } from '../input/contract.ts' /** @@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts' * The wiring layer (T5) overlays this onto InputState.queue; the runtime * QueuedMessage and the input-contract QueuedMessage are structurally the * same frozen shape ({key, preview}). - * @param session - the resident session instance. + * @param session - the resident session face. * @returns the queue read face (snapshot reference stable while the queue is unchanged). */ -export function queueReadFaceOf(session: Session): ObservableSnapshot { +export function queueReadFaceOf(session: SessionFace): ObservableSnapshot { return { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0d2d8e9d8e..7b119b15ff 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -12,24 +12,50 @@ import type { Context } from 'cordis' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import { InputHub } from './input/hub.ts' +import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { InputService } from './input/contract.ts' + +/** + * The outward conversation face (`ctx.conversation`): the scope-addressed + * verbs and the input registry other plugins may reach — and exactly what a + * test fake must supply. + */ +export interface IConversation { + /** The per-session input machine registry (InputService face). */ + readonly input: InputService + /** + * Send a prompt into the caller scope's session. + * @param text - prompt text, sent verbatim as one text block. + * @param mode - queue after the current turn, or steer into it. + * @returns completion; business failures reject (and land in promptError). + */ + send(text: string, mode: 'queue' | 'steer'): Promise + /** + * Cancel the scoped session's in-flight turn. + * @returns completion; failures reject as in send. + */ + cancel(): Promise + /** + * Pull one older history page for the scoped session. + * @returns completion of the page pull. + */ + loadOlder(): Promise +} /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ -export class ConversationService extends Service { +export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ - readonly input: InputHub + readonly input: InputService /** * @param ctx - owning root context (the plugin apply context; the service * registers itself and follows that fiber's lifetime). - * @param config - the shared InputHub constructed by the plugin apply - * (shared with the slot inject factories); absent = own instance - * (object-layer tests that never touch slots). + * @param config - carries the InputService instance constructed by the + * plugin apply (the same InputHub the slot inject factories close over). */ - constructor(ctx: Context, config?: { input?: InputHub }) { + constructor(ctx: Context, config: { input: InputService }) { super(ctx, 'conversation') - this.input = config?.input ?? new InputHub(ctx) + this.input = config.input } /** @@ -57,8 +83,8 @@ export class ConversationService extends Service { await this.scopedSession('loadOlder').loadOlder() } - /** Resolve the caller scope's Session or throw on root contexts. */ - private scopedSession(op: string): Session { + /** Resolve the caller scope's session face or throw on root contexts. */ + private scopedSession(op: string): SessionFace { const id = this.scopeId(op) const binding = this.requireSessions().binding(id) if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`) @@ -74,10 +100,9 @@ export class ConversationService extends Service { return id } - private requireSessions(): SessionsService { - // ctx.get instead of ctx.sessions: the typed Context merge is suspended - // while the client/host `sessions` declaration collision awaits - // arbitration (see the runtime package's Context merge note). + private requireSessions(): ISessions { + // Strict ctx.get, not the injection proxy: the scope-addressed pattern + // reads the service off whatever context the tracker rebound. const sessions = this.ctx.get('sessions') if (sessions === undefined) throw new Error('conversation: sessions service unavailable') return sessions diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 2dd8aafb4e..bb78596a0b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -17,14 +17,16 @@ import { ThemePresenter } from './theme-presenter.ts' // Contract surface only (export-convergence rule: cross-package consumers // keep a symbol exported; test-only/package-internal symbols live off /src). -// LayoutService: the ctx.layout service class (consumers type against it). +// ILayout: the ctx.layout face consumers and test fakes type against. // OwnerShare contracts below are the render-side halves registrants compose // against; the frame components and the store factory are package-internal. export { LayoutService } from './service.ts' +export type { ILayout } from './service.ts' declare module 'cordis' { interface Context { - layout: LayoutService + /** The outward face only; the concrete service stays inside this plugin. */ + layout: import('./service.ts').ILayout } } diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 6b80644855..ecfd862a03 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -14,8 +14,23 @@ import type { createLayoutStore } from './stores.ts' /** The layout store's bound action set (framework-baked, draft params peeled). */ export type PanelActions = BoundActions> +/** + * The outward layout face (`ctx.layout`): the panel transitions other + * plugins may trigger — and exactly what a test fake must supply. The + * attachPanels wiring hook stays on the concrete class (root-entry assembly + * only). + */ +export interface ILayout { + /** Toggle the sidebar panel (closed ⟷ contract default width). */ + toggleSidebar(): void + /** Open the details panel (no-op when already open). */ + openDetails(): void + /** Close the details panel. */ + closeDetails(): void +} + /** Cross-plugin panel-action face (ctx.layout). */ -export class LayoutService { +export class LayoutService implements ILayout { #panels: PanelActions | undefined /** diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index 4f192d066e..f1c1d1953e 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -24,7 +24,8 @@ export type { SlashServiceContract } from './contract.ts' declare module 'cordis' { interface Context { - slash: SlashService + /** The outward face only; the concrete service stays inside this plugin. */ + slash: import('./contract.ts').SlashServiceContract } } diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index c47d44c3d4..c5448a3d50 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -7,7 +7,7 @@ */ import { Service } from 'cordis' import type { Context } from 'cordis' -import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '../types.ts' import { SlashController } from './controller.ts' import type { SlashServiceContract } from './contract.ts' @@ -99,7 +99,7 @@ export class SlashService extends Service implements SlashServiceContract { return controller } - private sessions(): SessionsService { + private sessions(): ISessions { const sessions = this.ctx.get('sessions') if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable') return sessions From c3d9f132e8f62ff8a7498936720e1e196916d669 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:46:01 +0800 Subject: [PATCH 53/61] test(snapshot): canonicalize generated cwd tokens --- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +-- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 2 +- .../goal-snapshots/goal-session/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../snapshots/bash-tool-turn/session.jsonl | 2 +- .../snapshots/both-mode-turn/session.jsonl | 2 +- .../snapshots/cancel-tool-calls/session.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../snapshots/code-mode-turn/session.jsonl | 2 +- .../code-mode-workspace-context/session.jsonl | 6 ++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../empty-response-retry/session.jsonl | 2 +- .../snapshots/error-finish/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 2 +- .../escalation-rejected/session.jsonl | 2 +- .../tests/snapshots/fs-edit/session.jsonl | 6 ++-- .../fs-escalation-approved/session.jsonl | 4 +-- .../snapshots/fs-policy-reject/session.jsonl | 8 ++--- .../snapshots/fs-read-window/session.jsonl | 4 +-- .../tests/snapshots/fs-read/session.jsonl | 4 +-- .../fs-write-overwrite/session.jsonl | 6 ++-- .../tests/snapshots/fs-write/session.jsonl | 4 +-- .../hook-cc-posttool-block/session.jsonl | 2 +- .../hook-cc-posttool-context/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-cc-stop-continue/session.jsonl | 2 +- .../hook-codex-posttool-block/session.jsonl | 2 +- .../hook-codex-posttool-context/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-stop-continue/session.jsonl | 2 +- .../snapshots/lsp-definition/session.jsonl | 2 +- .../tests/snapshots/multi-turn/session.jsonl | 2 +- .../snapshots/packed-chunks/session.jsonl | 2 +- .../parallel-tool-calls/session.jsonl | 6 ++-- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../snapshots/repeat-tool-guard/session.jsonl | 2 +- .../session-query-spill/session.jsonl | 2 +- .../session-title-after-turn/session.jsonl | 2 +- .../tests/snapshots/skill-load/session.jsonl | 4 +-- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 2 +- .../snapshots/subagent-fork/session.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.2.jsonl | 2 +- .../snapshots/subagent-mixed/session.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 2 +- .../snapshots/subagent-multi/session.2.jsonl | 2 +- .../snapshots/subagent-multi/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 2 +- .../snapshots/subagent-spawn/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../tests/snapshots/todo-write/session.jsonl | 2 +- .../snapshots/tool-call-turn/session.jsonl | 2 +- .../tests/snapshots/web-fetch/session.jsonl | 2 +- .../snapshots/workflow-run/session.1.jsonl | 2 +- .../snapshots/workflow-run/session.jsonl | 2 +- .../snapshots/workspace-context/session.jsonl | 4 +-- .../snapshots/workspace-edit/session.jsonl | 4 +-- .../headless-agent/tests/headless.snapshot.ts | 6 ++-- .../advanced-toolchain/session.1.jsonl | 4 +-- .../advanced-toolchain/session.2.jsonl | 4 +-- .../advanced-toolchain/session.jsonl | 4 +-- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../snapshots/ralph-loop/session.1.jsonl | 2 +- .../snapshots/ralph-loop/session.2.jsonl | 2 +- .../tests/snapshots/ralph-loop/session.jsonl | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 6 ++-- .../tests/snapshots/bash-tool/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 2 +- .../snapshots/subagent-spawn/session.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../bash-terminal-card/session.jsonl | 2 +- .../code-mode-dispatch-spill/session.jsonl | 4 +-- .../tests/snapshots/code-mode/session.jsonl | 2 +- .../cordis-dynamic-toolchain/session.1.jsonl | 2 +- .../cordis-dynamic-toolchain/session.2.jsonl | 2 +- .../cordis-dynamic-toolchain/session.jsonl | 2 +- .../dynamic-workflow/session.1.jsonl | 2 +- .../snapshots/dynamic-workflow/session.jsonl | 2 +- .../multi-turn-conversation/session.jsonl | 2 +- .../tests/snapshots/todo-plan/session.jsonl | 2 +- examples/tui-agent/tests/tui.snapshot.ts | 6 ++-- .../support/acp-snapshot/README.i18n.yaml | 4 +-- packages/support/acp-snapshot/README.md | 4 +-- packages/support/acp-snapshot/README.zh.md | 4 +-- packages/support/acp-snapshot/src/index.ts | 2 +- .../support/acp-snapshot/src/normalize.ts | 30 ++++++++----------- packages/support/acp-snapshot/src/suite.ts | 4 +-- .../acp-snapshot/tests/normalize.spec.ts | 18 +++++------ .../support/acp-snapshot/tests/suite.spec.ts | 4 +-- 99 files changed, 153 insertions(+), 157 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 0273e29aae..f126d6a85b 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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 .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: fd600830e36f70d74746cdaf01e762ff9ad4123b -2026-06-19-acp-snapshot-tests.zh.md: c444f8f7e44fd34ee54871d486f4b44aa0fd68c3 +2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f +2026-06-19-acp-snapshot-tests.zh.md: 5c239d2c2589708050cd6199231ec5047f225107 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index fd600830e3..e118ada582 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ A snapshot run asserts **two** normalized surfaces, because the harness's extern The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. -Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated temporary workspace as `{{tmpdir}}/` in the replay fixture, including filesystem-resolved aliases, so one fixture is portable across platform temp roots; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. ### Isolation: normalization now, sandbox later diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index c444f8f7e4..5c239d2c25 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -57,7 +57,7 @@ Status: implemented 两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。 -规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的临时 workspace 存储为 `{{tmpdir}}/`,包括文件系统解析出的别名,从而让同一 fixture 可在采用不同临时根目录的平台间移植;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 ### 隔离:当前靠归一化,后续可加沙箱 diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl index 80b225c03f..ac4615c377 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"{{tmpdir}}/goal-session-placeholder"} +{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"{{cwd}}"} 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 0d58cbde53..7ca8f10e3e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"64837546-93f0-46bd-83ec-2649c2497663"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 b01355ce3c..c41e5a26b8 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"043ede8b-08c4-4148-8bca-e2e82337c799"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index cace7471f1..f30effe77c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-acp","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"4e4ce615-aa57-45de-8dd5-971a72d988ac"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 8d882d3e5d..66db912c04 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f4bbe58d-7866-403f-a9ea-c7f8f7d4b103"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl index de67b40494..2b01cee430 100644 --- a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{tmpdir}}/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"798335c8-fbbf-4eef-a5af-de47d230b7eb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352050753,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 da8ad99604..2088815247 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"{{tmpdir}}/acp-snap-cwd-gRpiz3","delegationDepth":0} +{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014504349,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014504350,"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"},"role":"user","id":"87f8c6e9-fdbb-4b1a-b94d-f155aae58149"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014504359,"data":{"title":"Call the run_code tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index 150df245be..65a6a7f57c 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"},"role":"user","id":"37d9d206-cab7-450f-bff6-63a2dddd5f61"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784437195072,"data":{"title":"Run two shell commands: wait","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 099e310fc3..2d3039eab9 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"},"role":"user","id":"f91a282f-c2ba-4759-a3ac-fc24d5db909b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Start a long task; this","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index c10fa03521..0c9b180e65 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"{{tmpdir}}/acp-snap-cwd-as7fsu","delegationDepth":0} +{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014439576,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014439577,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"41779665-2808-4d84-a0a6-0ee5cb76fb06"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014439584,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index c3209b50e6..84d7253a2d 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"{{tmpdir}}/acp-snap-cwd-muJYhO","delegationDepth":0} +{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"6d0020b8-1a0e-489d-a2a2-7e820a403324"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,8 +16,8 @@ {"type":"assistant/message","seq":101,"time":1785122256269,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"497356eb-0561-4849-8d2a-02bebadcd432"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} {"type":"tool/call","seq":102,"time":1785122256269,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} {"type":"tool/code-dispatch-start","seq":103,"time":1785122256332,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} -{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{tmpdir}}/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} {"type":"user/message","seq":106,"time":1785122256338,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"90d60955-ebee-408a-8d12-41a305b3bf99"},"surfaceOp":"append"} {"type":"step/end","seq":107,"time":1785122256338,"data":{"turn":1,"step":1}} {"type":"step/start","seq":108,"time":1785122256347,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 38bff405f5..042753f1a7 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{tmpdir}}/cordis-inspect-jsdoc","delegationDepth":0} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"48efc8f5-a397-491b-b7a1-179a1185ac2f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784449176718,"data":{"title":"Inspect the exact tools service","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index 8c9343ab6e..23b9fc2443 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"c9828d19-2c86-4a4f-9868-c9c28f345358"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index 0f3efd33ad..afb6bead2b 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"3d8fced9-efab-4698-b76a-e452746fadc6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt triggers a recorded","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 1d90e6abd8..0985cbd3de 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"{{tmpdir}}/acp-snap-cwd-cbBLh2","delegationDepth":0} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821261714,"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"},"role":"user","id":"8fcf378f-b720-4a86-be32-95ddec1651c3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821261714,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 25f7f7d635..35aebd255b 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"{{tmpdir}}/acp-snap-cwd-Hn29Od","delegationDepth":0} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821263241,"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"},"role":"user","id":"1f206016-2423-4b51-80bb-df15468298c5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821263241,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index e2899ba345..784b6c17c4 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"{{tmpdir}}/acp-snap-cwd-0BxHdV","delegationDepth":0} +{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352084740,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352084740,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"6b1ee31e-9c1a-41f3-9647-153d6d98e1a5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352084740,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{tmpdir}}/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}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"c9b571e1-3a63-4a97-af3e-41ac1bdc8e24"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"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":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{tmpdir}}/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index a0eb0d5f6f..79ef6a1131 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"{{tmpdir}}/acp-snap-cwd-vmEGzd","delegationDepth":0} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821264846,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"e4d528b4-0dd8-4aa9-853e-3d00f25b31aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821264846,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,7 +16,7 @@ {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 cc43daf7bd..d934a2d7be 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"{{tmpdir}}/acp-snap-cwd-QzoqnB","delegationDepth":0} +{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611702550,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611702550,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"c367f2cd-f9b5-44a4-a363-fdb97d469ad2"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783611702550,"data":{"title":"Do NOT use the read","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db6924d3-7ca0-4a50-9bec-9f976b1f493d"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":78,"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":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} +{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -40,7 +40,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0e331a4c-4e8d-4daf-a909-80d78a03bdf7"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"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":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{tmpdir}}/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 052ccce5f9..72cb3a5200 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"{{tmpdir}}/acp-snap-cwd-N9HCkt","delegationDepth":0} +{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352099838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352099839,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"80cf70ac-0b37-401a-96d2-c54056300cd4"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352099839,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","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}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":92,"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":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{tmpdir}}/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}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} +{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","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"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 62cfbff6c7..82adec999d 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"{{tmpdir}}/acp-snap-cwd-PEETkS","delegationDepth":0} +{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352072468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352072469,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"7396fa9a-4068-42a6-b153-2b5ade098d32"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352072469,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 96da3ed2c1..e46bcfa17c 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"{{tmpdir}}/acp-snap-cwd-hH2sGY","delegationDepth":0} +{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352092220,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352092221,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"5e890158-f455-445a-b265-e0cd1b18af36"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352092221,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"17d77822-5c76-4c09-acb6-8ff891129da8"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"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/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 90440eaf45..8d58e22ecc 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"{{tmpdir}}/acp-snap-cwd-sNvn5N","delegationDepth":0} +{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352078754,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352078754,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"f11a6473-4b11-4205-a73a-edd879e1ec56"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352078754,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6a58e768-f922-4850-832e-bfb43d4ab4fc"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":63,"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":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index 5536ca446e..a43c898146 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"{{tmpdir}}/acp-snap-cwd-WxJGUY","delegationDepth":0} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"775ddb99-fdd1-404f-ba14-4cc37b6ac2c8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783962504117,"data":{"title":"Call the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index 3be55c79c5..8bcd0df48f 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"{{tmpdir}}/acp-snap-cwd-LEetSL","delegationDepth":0} +{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352196662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352196662,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"b3957310-0893-4e41-88b2-715c102b5a9a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352196662,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} 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 0f01aa991d..5e3bdb0217 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 @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"{{tmpdir}}/acp-snap-cwd-iKVciS","delegationDepth":0} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"40085b3d-6b87-4b86-859e-b34786c9a12f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 58199bf49e..b956a3f054 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{tmpdir}}/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"57df50c1-78e1-4b8a-857a-c2ae2192dadd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index d464ba80a1..0aeb20331c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"{{tmpdir}}/acp-snap-cwd-QUDqlk","delegationDepth":0} +{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"7911469c-1e33-4741-9d32-49ecc6a01f0b"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"7b887c49-97bd-46f9-aea4-c462d385a8ee"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 26a57d5b5d..51068cd22e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"{{tmpdir}}/acp-snap-cwd-r6rWZp","delegationDepth":0} +{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c63da2f2-916d-42cc-8e6f-c9520e1641cd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522140647,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index aa5caf16f8..67f277d2cd 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"{{tmpdir}}/acp-snap-cwd-x67BsP","delegationDepth":0} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"5a3821d5-de5b-4b9c-85b7-d53dca51af5c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783986962235,"data":{"title":"Call the bash tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index f577fa4b0b..b1d297049d 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"{{tmpdir}}/acp-snap-cwd-VGFtPi","delegationDepth":0} +{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352228441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352228442,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"7d8954d3-d4e7-4ca6-ba3d-0c5de95a3ace"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352228442,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 7d0ae9c642..97e59bbe8f 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"{{tmpdir}}/acp-snap-cwd-7Hbu0m","delegationDepth":0} +{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352214604,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352214605,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8aab0b74-e7e0-4c3c-90a3-19a81f2b9c6a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352214605,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 52038a11ce..5ffc12a991 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"{{tmpdir}}/acp-snap-cwd-aopaZV","delegationDepth":0} +{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ea34d65f-e154-4b2a-bea8-3345fdd96658"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"174d8732-a32f-4eb0-8471-d8b3291a34f2"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index d862c273a0..18d6740b2b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"{{tmpdir}}/acp-snap-cwd-ESgqLu","delegationDepth":0} +{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c76f1de4-cf89-4f0f-a861-bc699f579f78"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522152397,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index f8f0d912e3..24c678f292 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"4133e3ae-3f16-4e96-b6dc-5b194fcd9a50"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the lsp tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 9e6a2ad199..2cc17bdcb1 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{tmpdir}}/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"77c88536-5dcd-423c-b2f1-c432d5f057fd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352113765,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl index 2bf42fa2a6..76f43e17c4 100644 --- a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl +++ b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{tmpdir}}/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"a597583b-7e90-4d4d-9b6a-bb1ab7617417"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl index ce5d64499f..9df7e1485d 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"b4f8388c-8494-409b-8230-c98e14e0899b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,8 +15,8 @@ {"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"380ff5b4-d7f1-4c36-b87d-9a42ce1b264c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} {"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} -{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl index cedd8cf0a2..a0ebe7a13d 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"f8d5e91c-eb5a-4223-8295-acf7ff357ccc"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index 2248a9148a..8c3644959c 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"c7f37e71-3cad-428e-b267-311499b38e9d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Write the todo list 'watch","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index ee8f640a05..c4dea917f0 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"4cca69f9-35bf-4a89-ad5e-c36296496f75"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 4 with","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl index bca51d0679..4c2edbcf5d 100644 --- a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"{{tmpdir}}/session-title-after-turn","delegationDepth":0} +{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"00000000-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f3ef5e874e..cf5b8e6026 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{tmpdir}}/acp-snap-cwd-DhYwNW","delegationDepth":0} +{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{cwd}}","delegationDepth":0} {"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"},"role":"user","id":"9c670f1c-3508-4b98-9cae-21f363652d6e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1784903324935,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cc7d430d-d011-4428-8572-0274c6082277"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[6,7,8,9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784903324936,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{tmpdir}}/acp-snap-cwd-DhYwNW/.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"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","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"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784903324944,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1784903324952,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 68e9154acc..9559b5b378 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{tmpdir}}/subagent-depth-two","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1784540790312,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790312,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"e1664eb5-480b-4987-a0a3-4fcd85ccb04d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790312,"data":{"title":"Call subagent once. Ask that","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 1aadcf6302..a9493cbac8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{tmpdir}}/subagent-depth-two","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} {"type":"turn/start","seq":0,"time":1784540790319,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790319,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"9299d7d1-85e0-4e05-93e4-34d2cf6bafc8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790319,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index d32f31d9a7..ab2d26180c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{tmpdir}}/subagent-depth-two","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784540790290,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790291,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"f74eb6a3-3869-4b1c-ba3c-5b6db530ac67"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790291,"data":{"title":"Delegate through two child generations.","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 309eff763f..6d5b0d9163 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{tmpdir}}/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} +{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index 6458ab9c98..a0b09e9478 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"{{tmpdir}}/acp-snap-cwd-0HLtcD","delegationDepth":0} +{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 5ba2047d2c..fe94af0f52 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} +{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"214ad816-8421-48ff-b501-ca51716d761f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352145224,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 5bed074a44..1b9127889d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} +{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index 51b1539129..0ee3d0a595 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"{{tmpdir}}/acp-snap-cwd-i43JSF","delegationDepth":0} +{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index e2dabc8463..ae13a1f327 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"4088c6ea-4806-4d0a-a5a7-b430ba9fcb7e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352127670,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index b181e92ec9..6939aef6c0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"804b9ed3-e2ed-495e-9840-8e0f657661fe"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352129662,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index f0805a895c..303ceb6e6b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"{{tmpdir}}/acp-snap-cwd-28z5Of","delegationDepth":0} +{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"26ff1621-20b5-4c1e-b546-ed4c6f6ec99e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352126251,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 680c7505ea..38534a09cf 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{tmpdir}}/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} +{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f3a2e52a-cfc3-4f9a-b25a-cb48f61e598e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352120856,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index 3374bc0172..f35595a402 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"{{tmpdir}}/acp-snap-cwd-rbeWyt","delegationDepth":0} +{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"36c0b82b-ab96-4985-9b44-8895eeedd725"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352119274,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 8f4e11008d..348c891312 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{tmpdir}}/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} {"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"},"role":"user","id":"2da6fcd7-2410-460a-bb8f-bc6491f7b0b0"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index fc5a806b56..429c76226e 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{tmpdir}}/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"18c389cb-ab26-4a60-96aa-a1314eab3759"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352057655,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 4f30f2987e..24ea03494f 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"{{tmpdir}}/acp-snap-cwd-OwUkBh","delegationDepth":0} +{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352044771,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352044771,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"033e6f20-6021-4ecc-a80f-de758a3dc877"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352044771,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index 7a3812162f..396860773e 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"{{tmpdir}}/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"6c8e9279-bb26-4369-b425-951cd33d6b15"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 30b43a8e38..268c7db0f6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"660a2954-67fc-4406-8703-189f3c0ee81e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600636316,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 3d3580ef08..aa29969cf5 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"7752d242-0fc3-421c-ad28-60333479140c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600631838,"data":{"title":"Use the workflow tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index a1aa002ccf..da3bbd7ad7 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7bee8c9d-684e-42e2-a906-54479a4360c0"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -12,7 +12,7 @@ {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"11b21c20-5425-41ad-8fa0-d8b89cc40f87"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{tmpdir}}/{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} {"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"73cb82c7-85c5-4d87-bb6c-cad10b7ef6de"},"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 6cdf389f35..eaee6bd14b 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"{{tmpdir}}/acp-snap-cwd-rxbEpP","delegationDepth":0} +{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352264080,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352264081,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"77ac6781-b796-4060-b670-63baa39a986b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352264081,"data":{"title":"A file named greeting.txt in","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{tmpdir}}/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index c09ef9ee4b..2581d8a042 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -7,7 +7,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, - tokenizeSessionFixtureTmpdir, + tokenizeSessionFixtureCwd, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -261,7 +261,7 @@ describe('headless stream-json snapshots', () => { if (existing === undefined || file === undefined) { throw new Error(`headless snapshot has no fixture for persisted log ${index}`) } - const stable = tokenizeSessionFixtureTmpdir( + const stable = tokenizeSessionFixtureCwd( stabilizeRefreshLog(actual.content, existing, replacements, actualContext), ) await writeFile(file, stable) @@ -459,7 +459,7 @@ describe('headless stream-json snapshots', () => { content: actual.content, } const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = tokenizeSessionFixtureTmpdir( + expectedSession = tokenizeSessionFixtureCwd( stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), ) await writeFile(ptySessionFixture, expectedSession) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 536290c221..9e42e8e8e9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"b07a1eeb-2060-44e5-87d3-05d315a4a74b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index a311ff1a80..6d451457fc 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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"},"role":"user","id":"1d565b63-5689-4c09-9686-abd3ee379e28"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 59535957c1..c678c2214f 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-headless","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"0dda35fe-e148-4400-b837-2f6e6fe40ae6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{tmpdir}}/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** 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. The 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?, provider?, 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. Script-body hooks: - `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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `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. - `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`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, 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/oneOf — no 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), and independent `provider`/`model` LLM target overrides (either may be provided alone). 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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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":7,"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/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index c5c61ec9e6..cda0e3e2f6 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{tmpdir}}/{{cwd}}","delegationDepth":0} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"8cc78530-3ead-4c68-a38f-dcc14d6a2a82"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl index d9b1fe3d3c..d684022863 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"{{tmpdir}}/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl index a478992707..15f54b231b 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"{{tmpdir}}/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl index adcc6a5092..4b6fe2dcb5 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"{{tmpdir}}/ralph-headless"} +{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"{{cwd}}"} diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index f09242106c..c54812e3e5 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -20,7 +20,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, - tokenizeSessionFixtureTmpdir, + tokenizeSessionFixtureCwd, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -239,7 +239,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { await Promise.all(ordered.map(async (log, index) => { const file = fixtureFiles(scenario)[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureTmpdir(log.content))) + await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))) })) } @@ -258,7 +258,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const existing = expectedContents[index] const file = files[index] if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = scrubRequestHeaders(tokenizeSessionFixtureTmpdir( + const stable = scrubRequestHeaders(tokenizeSessionFixtureCwd( stabilizeRefreshLog(log.content, existing, replacements, actualContext), )) await writeFile(file, stable) diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index c633ef6a37..a1e3925531 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{tmpdir}}/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"295507c3-4ba7-4695-a535-73e75046abb3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 2e121dfaba..7b0db305f6 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{tmpdir}}/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} +{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{cwd}}","parentSession":"sdk-snapshot-subagent","delegationDepth":1} {"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"fb1dfb09-5b8b-4343-8a04-49cc4c7c082e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index 7db65da9ba..f43a78f588 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{tmpdir}}/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"e2664740-19d2-4e54-81e5-63ff154af28e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index 4827a0548f..d7192b0a12 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{tmpdir}}/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"4cb523e7-19c9-45d0-8799-911a78c26207"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl index 5e17b8f7c1..4d0354a167 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{tmpdir}}/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl index 2c47890942..3ae5c51857 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"{{tmpdir}}/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia"} +{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":1785052797817,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[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,151,152,153,154,155],"surfaceOp":"append"} {"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}} {"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}} -{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{tmpdir}}/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} +{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{cwd}}/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} {"type":"tool/result","seq":160,"time":1785052799925,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","content":[{"type":"text","text":"200"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} {"type":"step/end","seq":161,"time":1785052799926,"data":{"turn":1,"step":1}} {"type":"step/start","seq":162,"time":1785052799928,"data":{"turn":1,"step":2}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl index 2ec7e9f98c..7af580f60a 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"{{tmpdir}}/dsh-tui-snapshot-code-mode-7FNsKO"} +{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":1785014512139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl index 32354715bc..7027bd50f3 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl index 59ae1a98af..5d383f6421 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{tmpdir}}/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"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}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index e95e7cebe1..0e9355b4c5 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{tmpdir}}/advanced-acp","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"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: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop 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}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl index fce55b2b16..42b73e2084 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl index b411e2f12d..9083060639 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{tmpdir}}/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl index da94ddbd89..549dc342a7 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{tmpdir}}/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl index 7d183a245b..3591582b9b 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{tmpdir}}/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 545ce29862..af7c027d6d 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scrubRequestHeaders, tokenizeSessionFixtureTmpdir } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -417,13 +417,13 @@ async function writeRecording(scenario: Scenario, result: ScenarioResult): Promi await mkdir(dir, { recursive: true }) await writeFile( join(dir, 'session.jsonl'), - scrubRequestHeaders(tokenizeSessionFixtureTmpdir(rawSessionLog(result.parent))), + scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))), ) expect(result.children).toHaveLength(scenario.childSessions ?? 0) for (const [index, child] of result.children.entries()) { await writeFile( join(dir, `session.${index + 1}.jsonl`), - scrubRequestHeaders(tokenizeSessionFixtureTmpdir(rawSessionLog(child))), + scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))), ) } } diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 7c29312084..3c59b2ba93 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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 packages/support/acp-snapshot/README.md -README.md: 0bc27e263c9dbbeb83bd56628e96c9fe903ea722 -README.zh.md: acee0f9da993dde96c6ce6be9aa10b56ebf3044b +README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27 +README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 0bc27e263c..43666d2d11 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,7 +8,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureTmpdir` (the generated temporary-workspace root → `{{tmpdir}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default temporary workspaces are stored in session fixtures as `{{tmpdir}}/` so macOS and Linux recordings converge; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index acee0f9da9..608b2490f5 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,7 +8,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureTmpdir`(生成的临时 workspace 根目录 → `{{tmpdir}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认临时 workspace 在会话 fixture 中存储为 `{{tmpdir}}/`,使 macOS 与 Linux 录制结果一致;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index d6e2552d55..09d05031c7 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -38,7 +38,7 @@ export { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, - tokenizeSessionFixtureTmpdir, + tokenizeSessionFixtureCwd, type CwdPathMode, type NormalizeContext, type NormalizeOptions, diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 387be8c250..f984c231ea 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -8,7 +8,6 @@ const SESSION_ID = '{{sessionId}}' const CWD = '{{cwd}}' -const TMPDIR = '{{tmpdir}}' const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' const EVENT_TIME = '{{eventTime}}' @@ -188,46 +187,44 @@ function escapeRegExp(value: string): string { } /** Replace any absolute spelling whose final segment is the generated cwd basename. */ -function tokenizeFixtureString(value: string, ctx: NormalizeContext, portableCwd: string, basename: string): string { - const exact = replaceCwd(value, ctx, portableCwd) +function tokenizeFixtureString(value: string, ctx: NormalizeContext, basename: string): string { + const exact = replaceCwd(value, ctx, CWD) const absoluteCwd = new RegExp( String.raw`(?:[A-Za-z]:)?[\\/](?:[^\\/\s<>"]+[\\/])*${escapeRegExp(basename)}` + String.raw`(?=$|[\\/\s<>'"()\[\]{},;:!?=])`, 'g', ) - return exact.replace(absoluteCwd, (match, offset: number) => - exact.slice(0, offset).endsWith(TMPDIR) ? match : portableCwd) + return exact.replace(absoluteCwd, CWD) } /** Recursively replace generated-cwd spellings while preserving every other JSON value. */ function tokenizeFixtureValue( value: unknown, ctx: NormalizeContext, - portableCwd: string, basename: string, ): unknown { - if (typeof value === 'string') return tokenizeFixtureString(value, ctx, portableCwd, basename) - if (Array.isArray(value)) return value.map(item => tokenizeFixtureValue(item, ctx, portableCwd, basename)) + if (typeof value === 'string') return tokenizeFixtureString(value, ctx, basename) + if (Array.isArray(value)) return value.map(item => tokenizeFixtureValue(item, ctx, basename)) if (value !== null && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([key, item]) => [ key, - tokenizeFixtureValue(item, ctx, portableCwd, basename), + tokenizeFixtureValue(item, ctx, basename), ])) } return value } /** - * Store one generated temporary workspace with a platform-neutral root while - * retaining its run-specific basename and every other session value. The - * caller opts in only for workspaces created under a platform temporary root; - * explicitly relocated workspaces keep their real path. + * Store one generated workspace as `{{cwd}}` while retaining every other + * session value. The caller opts in only for workspaces created under a + * platform temporary root; explicitly relocated workspaces keep their real + * path. * * @param rawLog The raw or refresh-stabilized session JSONL fixture. - * @returns Compact JSONL whose known cwd spellings start with `{{tmpdir}}`. + * @returns Compact JSONL whose known cwd spellings become `{{cwd}}`. * @throws If a non-empty line is invalid JSON or the session cwd has no basename. */ -export function tokenizeSessionFixtureTmpdir(rawLog: string): string { +export function tokenizeSessionFixtureCwd(rawLog: string): string { const lines = rawLog.split('\n') const firstLine = lines.find(line => line.trim().length > 0) const header = firstLine === undefined ? undefined : JSON.parse(firstLine) as { cwd?: unknown } @@ -236,11 +233,10 @@ export function tokenizeSessionFixtureTmpdir(rawLog: string): string { if (basename === undefined || basename.length === 0) { throw new Error('acp-snapshot: cannot tokenize a cwd without a basename') } - const portableCwd = `${TMPDIR}/${basename}` const ctx: NormalizeContext = { sessionIds: [], cwd } return lines.map((line) => { if (line.trim().length === 0) return line - return JSON.stringify(tokenizeFixtureValue(JSON.parse(line), ctx, portableCwd, basename)) + return JSON.stringify(tokenizeFixtureValue(JSON.parse(line), ctx, basename)) }).join('\n') } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 9ba8eda8af..d04a481993 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -31,7 +31,7 @@ import { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, - tokenizeSessionFixtureTmpdir, + tokenizeSessionFixtureCwd, } from './normalize.ts' /** The readable system-prompt snapshot beside its owning header pin. */ @@ -1009,7 +1009,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log)) : scrubRequestHeaders const portableFixture = scenario.workspaceParent === undefined - ? tokenizeSessionFixtureTmpdir + ? tokenizeSessionFixtureCwd : (log: string): string => log const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index e882ac2c63..a2c824519e 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -7,7 +7,7 @@ import { scrubRequestHeaders, scrubSystemPrompts, scrubToolSchemas, - tokenizeSessionFixtureTmpdir, + tokenizeSessionFixtureCwd, } from '../src/normalize.ts' /** @@ -371,7 +371,7 @@ describe('normalizeSessionLog', () => { }) }) -describe('tokenizeSessionFixtureTmpdir', () => { +describe('tokenizeSessionFixtureCwd', () => { it.each([ { name: 'macOS', @@ -415,25 +415,25 @@ describe('tokenizeSessionFixtureTmpdir', () => { '', ].join('\n') - const out = tokenizeSessionFixtureTmpdir(raw) + const out = tokenizeSessionFixtureCwd(raw) const result = JSON.parse(out.split('\n')[1] as string) as { data: { content: { text: string }[] } } const resultText = (result.data.content[0] as { text: string }).text - expect(out).toContain('"cwd":"{{tmpdir}}/acp-snap-cwd-abc123"') - expect(resultText).toContain('wrote {{tmpdir}}/acp-snap-cwd-abc123/proof.txt') - expect(resultText).toContain('alias {{tmpdir}}/acp-snap-cwd-abc123/alias.txt') - expect(resultText).toContain('cwd {{tmpdir}}/acp-snap-cwd-abc123. Next') + expect(out).toContain('"cwd":"{{cwd}}"') + expect(resultText).toContain('wrote {{cwd}}/proof.txt') + expect(resultText).toContain('alias {{cwd}}/alias.txt') + expect(resultText).toContain('cwd {{cwd}}. Next') expect(resultText).toContain(`${context.cwd}-backup`) expect(resultText).toContain(`${context.cwd}.backup`) expect(resultText).toContain('/tmp/authored.txt') expect(resultText).not.toContain(`${reportedCwd}/proof.txt`) - expect(tokenizeSessionFixtureTmpdir(out)).toBe(out) + expect(tokenizeSessionFixtureCwd(out)).toBe(out) }) it('rejects a log without a session cwd', () => { - expect(() => tokenizeSessionFixtureTmpdir('')).toThrow( + expect(() => tokenizeSessionFixtureCwd('')).toThrow( 'acp-snapshot: cannot tokenize a cwd without a basename', ) }) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4660195eef..420dcef2cf 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -179,7 +179,7 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { expect(schemas).not.toContain('"name":"stale"') const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8') - expect(pinSession).toContain('"cwd":"{{tmpdir}}/pin-cwd"') + expect(pinSession).toContain('"cwd":"{{cwd}}"') }) }) @@ -187,7 +187,7 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => { it('creates a missing primary fixture and prunes stale child fixtures', () => { const fixture = readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8') expect(fixture).toContain('"type":"session"') - expect(fixture).toContain('"cwd":"{{tmpdir}}/acp-snap-cwd-') + expect(fixture).toContain('"cwd":"{{cwd}}"') expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() }) }) From 3f7a8c6f8ebf3fc13e4500ddd47e8ccfc1581ce7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:04:22 +0800 Subject: [PATCH 54/61] feat(client): add the dsh-client-test-runtime package A jsdom slot test runtime for feature specs: a real Cordis Context, the production SlotsService and web-react renderer, and typed session/workspace doubles (TestSessions implements ISessions with FixtureSession sessions; TestWorkspaces implements IWorkspaces), so the compiler flags fixture drift when a production face changes. Fixtures feed plain data: list rows, conversation snapshots, and ISession-typed behavior stubs; provide-bundle materialization runs the shared SessionProvideChannel. DOM snapshot support: declare()/renderSlot() mount a single slot inside a data-slot wrapper for local .snap capture, and a snapshot serializer folds CSS-module class hashes to their semantic locals and collapses svg internals to a content fingerprint. The typed provide() constrains declared-service fakes to Partial of the service's outward face. --- docs/config-catalog.md | 1 + docs/module-graph.md | 6 + packages/client/test-runtime/README.i18n.yaml | 6 + packages/client/test-runtime/README.md | 24 + packages/client/test-runtime/README.zh.md | 24 + packages/client/test-runtime/package.json | 54 ++ packages/client/test-runtime/src/fixtures.ts | 82 +++ packages/client/test-runtime/src/index.ts | 372 ++++++++++++ packages/client/test-runtime/src/invariant.ts | 32 + packages/client/test-runtime/src/sessions.ts | 390 +++++++++++++ packages/client/test-runtime/src/snapshot.ts | 89 +++ .../client/test-runtime/src/workspaces.ts | 147 +++++ .../tests/__snapshots__/runtime.spec.tsx.snap | 37 ++ .../test-runtime/tests/invariant.spec.ts | 12 + .../test-runtime/tests/runtime.spec.tsx | 546 ++++++++++++++++++ packages/client/test-runtime/tsconfig.json | 27 + pnpm-lock.yaml | 76 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 20 files changed, 1928 insertions(+) create mode 100644 packages/client/test-runtime/README.i18n.yaml create mode 100644 packages/client/test-runtime/README.md create mode 100644 packages/client/test-runtime/README.zh.md create mode 100644 packages/client/test-runtime/package.json create mode 100644 packages/client/test-runtime/src/fixtures.ts create mode 100644 packages/client/test-runtime/src/index.ts create mode 100644 packages/client/test-runtime/src/invariant.ts create mode 100644 packages/client/test-runtime/src/sessions.ts create mode 100644 packages/client/test-runtime/src/snapshot.ts create mode 100644 packages/client/test-runtime/src/workspaces.ts create mode 100644 packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap create mode 100644 packages/client/test-runtime/tests/invariant.spec.ts create mode 100644 packages/client/test-runtime/tests/runtime.spec.tsx create mode 100644 packages/client/test-runtime/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b5e320f7b7..7ff7f0faf2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2230,6 +2230,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 255202bbae..33e63fc805 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -141,6 +141,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] + pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] @@ -276,6 +277,10 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_invariants pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_ui_slots pkg_client_ui_models --> pkg_invariants @@ -946,6 +951,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml new file mode 100644 index 0000000000..73a4705b1b --- /dev/null +++ b/packages/client/test-runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md +README.md: 883d71224139dc409229fdfb35362d040e810cc7 +README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md new file mode 100644 index 0000000000..883d712241 --- /dev/null +++ b/packages/client/test-runtime/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-test-runtime + +English | [中文](README.zh.md) + +jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. + +The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. + +Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `
` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. + +Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only. + +## Model Experience + +None, as this package is browser-side test infrastructure; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists. +- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md new file mode 100644 index 0000000000..a3daf11294 --- /dev/null +++ b/packages/client/test-runtime/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-test-runtime + +[English](README.md) | 中文 + +面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 + +替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 + +局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 + +不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。 + +## Model Experience + +无;本包是浏览器侧测试基础设施,无一物到达模型请求。 + +#### KV Cache effect + +无;本包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。 +- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。 diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json new file mode 100644 index 0000000000..10b80bdd68 --- /dev/null +++ b/packages/client/test-runtime/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-client-test-runtime", + "description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause", + "dependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "vitest": "^4.1.8" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts new file mode 100644 index 0000000000..4219d233e2 --- /dev/null +++ b/packages/client/test-runtime/src/fixtures.ts @@ -0,0 +1,82 @@ +/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */ +import type { + ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Fixture overrides for the session behavior face: any subset of the + * production ISession verbs (typed against it, so a face change surfaces + * here at compile time), plus extra members feature-specific casts consume. + * The open Record tail means a misnamed EXTRA member is not caught by the + * compiler (it grafts as dead weight); the ISession verbs stay safe — a + * misnamed verb leaves the fail-loud stub in place, which names itself at + * the first call. + */ +export type SessionBehaviorOverrides = Partial & Record + +/** + * act-wrapped mutation runner shared by every runtime object: public mutators + * funnel through it so tests never handle SlotCore microtask batching or + * React act themselves. + */ +export type Stabilizer = (fn: () => void | Promise) => Promise + +/** + * Session fixture accepted by {@link TestSessions.add}: identity plus optional + * snapshot/list-row overrides and the session behavior face the feature under + * test actually calls (kept open — the runtime never fakes methods a test did + * not supply, so an unstubbed call fails loud at the call site). + */ +export interface SessionFixture { + id: string + /** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */ + snapshot?: Partial> + /** List-row overrides merged over the defaults derived from `id`. */ + summary?: Partial> + /** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */ + session?: SessionBehaviorOverrides +} + +/** + * A complete quiescent conversation snapshot (open window, no traffic). + * @param sessionId - owning session id. + * @returns the snapshot; spread fixture overrides on top. + */ +export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { + return { + sessionId, + nodes: [], + foldDegraded: false, + partial: null, + runningCalls: [], + codeDispatches: new Map(), + pending: [], + queue: [], + running: false, + composerPhase: 'active', + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + } +} + +/** + * A ready workspace list with no workspaces (the shape WorkspacesService + * projects after both baselines land). + * @returns the initial state of the test workspaces store. + */ +export function workspaceListState(): WorkspaceListState { + return { + items: [], + state: 'idle', + phase: 'ready', + error: null, + baselinesReady: true, + recentWorkspaceId: undefined, + } +} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts new file mode 100644 index 0000000000..2b9ea6afd0 --- /dev/null +++ b/packages/client/test-runtime/src/index.ts @@ -0,0 +1,372 @@ +/** + * jsdom slot test runtime: a real small runtime — Cordis `Context`, the + * runtime `SlotsService`, and the web-react renderer — assembled around + * test-owned session/workspace doubles, so feature specs exercise + * declaration, registration, scope, store, inject, rendering, updates, and + * disposal without hand-building the machinery per suite. + * + * Not part of the product plugin graph (no `dshClient`); feature packages + * depend on it in devDependencies only. It copies no SlotCore/renderer/store + * machinery — everything mounts the production implementations. + * @module @deepseek-ai/dsh-client-test-runtime + */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- + * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): + * this compilation unit sees only the runtime's 'root' row, but consumer + * programs merge their own keys in; the rule fires on the narrow-map view. */ +import { Context, Inject } from 'cordis' +import type { Fiber, Plugin } from 'cordis' +import { createElement, Fragment, useSyncExternalStore } from 'react' +import type { ReactNode } from 'react' +import { act, render, within } from '@testing-library/react' +import type { RenderResult } from '@testing-library/react' +import type { queries } from '@testing-library/dom' +import type { BoundFunctions } from '@testing-library/dom' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' +import type { + ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike, +} from '@deepseek-ai/dsh-client-ui-slots' +import { registerDomSnapshotSerializer } from './snapshot.ts' +import { TestSessions } from './sessions.ts' +import { TestWorkspaces } from './workspaces.ts' +import type { Stabilizer } from './fixtures.ts' + +export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' +export { FixtureSession, TestSessions } from './sessions.ts' +export { TestWorkspaces } from './workspaces.ts' +export { conversationSnapshot, workspaceListState } from './fixtures.ts' +export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' + +/** Erased register face for the internal root call (the public declare seam holds the typing). */ +type ErasedRegister = (options: object, component: unknown) => () => void + +/** + * One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}: + * the `data-slot` wrapper is the snapshot root (`expect(view.container) + * .toMatchSnapshot()` captures exactly this slot's output), Testing Library + * queries are bound inside it, and `update` re-renders with new owner props. + */ +export interface SlotView { + /** The `
` wrapper around the slot's rendered output. */ + readonly container: HTMLElement + /** Testing Library queries scoped to {@link SlotView.container}. */ + readonly view: BoundFunctions + /** + * Replace the owner props and flush the re-render (the render-site update: + * in production the owner recomputes the share and React re-renders). + * @param owner - the next owner props share. + */ + update(owner: OwnerOf): void +} + +/** + * Mounted feature plugin handle: the live fiber plus an act-wrapped, + * idempotent dispose (unload cascade: entries, declared child slots, store + * instances, and provided services all fall together). + */ +export interface FeatureHandle { + /** The plugin's live Cordis fiber (state assertions, escape hatch). */ + readonly fiber: Fiber + /** + * Dispose the plugin fiber inside React act; repeated calls no-op. + * @returns completion of the unload cascade. + */ + dispose(): Promise +} + +/** + * Owner-props cell behind the auto frame: one external store the frame + * subscribes to, so {@link SlotTestRuntime.renderSlot} and + * {@link SlotView.update} drive React through the standard uSES seam. + */ +class OwnerPropsCell { + private readonly owners = new Map() + private readonly listeners = new Set<() => void>() + private version = 0 + + /** Snapshot version for uSES pairing (bumped on every set). */ + readonly getVersion = (): number => this.version + + /** + * Subscribe to owner-props changes. + * @param fn - change callback. + * @returns unsubscribe. + */ + readonly subscribe = (fn: () => void): (() => void) => { + this.listeners.add(fn) + return () => { this.listeners.delete(fn) } + } + + /** + * Install or replace one key's owner props and notify (synchronous; the + * caller wraps in act). + * @param key - slot key. + * @param owner - owner props share. + */ + set(key: string, owner: object): void { + this.owners.set(key, owner) + this.version += 1 + for (const fn of [...this.listeners]) fn() + } + + /** Keys with supplied owner props, in first-supply order. */ + entries(): readonly (readonly [string, object])[] { + return [...this.owners.entries()] + } +} + +/** + * The test-owned 'root' occupant: declares the child slots a suite needs + * through the REAL `slots.register`, with a caller-supplied minimal frame — + * the runtime never guesses a feature's page structure. + */ +export class TestRoot { + private disposeEntry: (() => void) | undefined + + /** + * @param slots - the runtime SlotsService. + * @param stabilize - the owning runtime's act wrapper. + */ + constructor(private readonly slots: SlotsService, private readonly stabilize: Stabilizer) {} + + /** + * Register the root frame, declaring (and thereby claiming) the child + * slots. One declaration per runtime — a second call fails loud in the + * core ('root' is a single slot). + * @param children - child-slot declaration table (declaration + render authorization + runtime spec). + * @param frame - minimal frame component; its props derive from the declared keys (composed-props contract). + * @returns completion of the act-wrapped registration. + */ + async declare( + children: D, + frame: SlotComponent & keyof SlotMap & string, undefined, object>>, + ): Promise { + await this.stabilize(() => { + // Erased hop (same pattern as SlotsService's own implementation arm); + // the declare signature above is the typed seam. + this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame) + }) + } + + /** Remove the root registration and collapse its declarations (runtime dispose path). */ + release(): void { + this.disposeEntry?.() + this.disposeEntry = undefined + } +} + +/** + * The assembled test runtime. Obtain via {@link SlotTestRuntime.create}; + * dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators + * are act-wrapped throughout — tests never handle SlotCore microtask + * batching or React act themselves. + */ +export class SlotTestRuntime { + /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */ + readonly ctx: Context + /** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */ + readonly slots: SlotsService + /** The test-owned 'root' occupant. */ + readonly root: TestRoot + /** Sessions double (list/current observable, cells, scopes, behavior faces). */ + readonly sessions: TestSessions + /** Workspaces double (list observable, recorded intent actions). */ + readonly workspaces: TestWorkspaces + + private readonly stabilizer: Stabilizer = async (fn) => { + await act(async () => { await fn() }) + } + + private host: SlotRendererHost | undefined + private readonly views: RenderResult[] = [] + private readonly handles: FeatureHandle[] = [] + private disposed = false + /** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */ + private readonly ownerCell = new OwnerPropsCell() + private readonly autoDeclared = new Set() + private autoRootView: RenderResult | undefined + + private constructor(ctx: Context, slots: SlotsService) { + this.ctx = ctx + this.slots = slots + this.root = new TestRoot(slots, this.stabilizer) + this.sessions = new TestSessions(this.stabilizer, ctx) + this.workspaces = new TestWorkspaces(this.stabilizer) + ctx.provide('sessions', this.sessions) + ctx.provide('workspaces', this.workspaces) + // Capturing install: the production renderer does the rendering; the + // wrapper only takes the host face for storeOf (no machinery copied). + const renderer = createSlotRenderer() + slots.install({ + renderRoot: (host, ownerProps) => { + this.host = host + return renderer.renderRoot(host, ownerProps) + }, + }) + } + + /** + * Assemble a runtime: real Context, mounted SlotsService, installed + * renderer, and the session/workspace doubles provided as services. + * @returns the ready runtime. + */ + static async create(): Promise { + registerDomSnapshotSerializer() + const ctx = new Context() + const fiber = ctx.plugin(SlotsService) + await fiber.await() + return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService) + } + + /** + * Provide an extra service the feature under test injects (e.g. a layout + * fake). Sugar over `ctx.provide`, typed against the Context declaration + * merge: for a declared service name the fake must be a subset of that + * service's outward face (Partial — supply only what the feature calls), + * so a production face change breaks the fake at compile time. Undeclared + * names stay unchecked (ad-hoc test services). + * @param name - service name. + * @param value - service implementation (test double). + */ + provide(name: K, value: K extends keyof Context ? Partial : unknown): void { + this.ctx.provide(name, value) + } + + /** + * Mount a feature plugin on a real fiber. Required services are prechecked + * so a missing provider fails loud instead of suspending the fiber forever + * (deliberate load-order suspension tests use `ctx.plugin` directly). + * @param plugin - plugin value (function, class, or `{ inject, apply }` object). + * @returns handle owning the fiber's explicit disposal. + */ + async mount(plugin: Plugin): Promise { + const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject)) + const missing = required.filter(name => this.ctx.get(name) === undefined) + if (missing.length > 0) { + throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`) + } + const fiber = this.ctx.plugin(plugin) + await this.stabilizer(async () => { + await fiber.await() + }) + let disposed = false + const handle: FeatureHandle = { + fiber, + dispose: async () => { + if (disposed) return + disposed = true + await this.stabilizer(() => fiber.dispose()) + }, + } + this.handles.push(handle) + return handle + } + + /** + * Render the root slot tree through the ctx-level entry (the shell's own + * seam): `ctx.slots.renderSlot('root', {})` under Testing Library. + * @returns the Testing Library view. + */ + renderRoot(): RenderResult { + const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {}))) + this.views.push(view) + return view + } + + /** + * Declare child slots under an auto-generated root frame — the single-slot + * mounting path for local DOM snapshots. Each key later supplied through + * {@link SlotTestRuntime.renderSlot} renders inside its own + * `
` wrapper (the snapshot root). Mutually exclusive + * with {@link TestRoot.declare} ('root' is a single slot); one call per + * runtime. + * @param children - child-slot declaration table (same contract as TestRoot.declare). + * @returns completion of the act-wrapped registration. + */ + async declare(children: ChildrenDecl): Promise { + for (const key of Object.keys(children)) this.autoDeclared.add(key) + const cell = this.ownerCell + const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => { + useSyncExternalStore(cell.subscribe, cell.getVersion) + return createElement(Fragment, null, cell.entries().map(([key, owner]) => + createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner)))) + } + await this.root.declare(children as never, AutoFrame as never) + } + + /** + * Render one declared slot with its owner props and return the local view. + * The whole root tree mounts through the production assembly path + * (renderer, scope providers, store axis); only this key's output lands in + * the returned container. Call again with another key to view a sibling + * slot of the same tree. + * @param key - a key declared through {@link SlotTestRuntime.declare}. + * @param owner - owner props share for the render site. + * @returns the slot-local view (snapshot container, scoped queries, owner updates). + */ + renderSlot(key: K, owner: OwnerOf): SlotView { + if (!this.autoDeclared.has(key)) { + throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`) + } + const install = (next: object): void => { + // Synchronous cell write inside act: the frame re-renders through uSES. + act(() => { + this.ownerCell.set(key, next) + }) + } + install(owner) + this.autoRootView ??= this.renderRoot() + const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`) + if (!(container instanceof HTMLElement)) { + throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`) + } + return { container, view: within(container), update: install } + } + + /** + * Resolve the store instance the renderer would hand a slot's component + * (identity assertions, action-driven writes). Requires a prior + * {@link SlotTestRuntime.renderRoot} — the host face exists only inside the + * installed renderer, exactly as in production. + * @param key - slot key whose first entry declares the store. + * @param scopeKey - session id for session-scope slots; omit for root scope. + * @returns the live store instance. + */ + storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike { + if (this.host === undefined) { + throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer') + } + const entry = this.host.entriesOf(key)[0] + if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`) + const instance = this.host.storeOf(entry, scopeKey) + if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`) + return instance + } + + /** + * Flush pending ledger/store notifications inside act — for mutations made + * outside the runtime's own methods (e.g. a direct `slots.register`). + * @returns completion of the act pass. + */ + async flush(): Promise { + await this.stabilizer(() => {}) + } + + /** + * Tear down: unmount React trees first, then dispose feature fibers, the + * root registration, minted session scopes, and persisted test state. + * Idempotent. + * @returns completion of the teardown. + */ + async dispose(): Promise { + if (this.disposed) return + this.disposed = true + this.autoRootView = undefined + for (const view of this.views.splice(0)) view.unmount() + for (const handle of this.handles.splice(0)) await handle.dispose() + this.root.release() + await this.sessions.disposeScopes() + localStorage.clear() + } +} diff --git a/packages/client/test-runtime/src/invariant.ts b/packages/client/test-runtime/src/invariant.ts new file mode 100644 index 0000000000..09ef3da6ce --- /dev/null +++ b/packages/client/test-runtime/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`. + * @module @deepseek-ai/dsh-client-test-runtime/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime' + +/** Cordis companion plugin name. */ +export const name = 'client-test-runtime-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this test-support package owns no production event + * stream or mutable data — it assembles the runtime SlotsService and renderer + * (whose packages own their invariants) around test doubles; its own behavior + * is exercised by its package tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts new file mode 100644 index 0000000000..ebd419b369 --- /dev/null +++ b/packages/client/test-runtime/src/sessions.ts @@ -0,0 +1,390 @@ +/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ +import type { Context } from 'cordis' +import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, + SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import { conversationSnapshot } from './fixtures.ts' +import type { SessionFixture, Stabilizer } from './fixtures.ts' + +/** + * The fixture-backed session face: conversation reads delegate to the + * fixture's snapshot store; ISession verbs are fail-loud stubs unless the + * fixture supplies them (the runtime never fakes behavior a test did not + * declare — an unstubbed call names itself instead of half-working). Extra + * fixture methods are grafted verbatim for feature-side casts. + */ +export class FixtureSession implements SessionFace { + /** + * The useProjection seat: identity-stable per-key faces over the fixture's + * projection values (set via {@link TestSessions.setProjection}). + */ + readonly projections: ProjectionsFace & { set(key: string, value: unknown): void } + + /** + * @param sessionId - host identity (branded view of the fixture id). + * @param store - conversation snapshot store (updateSnapshot writes it). + * @param overrides - fixture-declared behavior face, grafted over the stubs. + */ + constructor( + readonly sessionId: SessionId, + private readonly store: SnapshotStore, + overrides: Record, + ) { + const values = new Map() + const listeners = new Map void>>() + const faces = new Map>() + this.projections = { + faceOf: (key: string) => { + let face = faces.get(key) + if (face === undefined) { + face = { + getSnapshot: () => values.get(key), + subscribe: (fn: () => void) => { + const set = listeners.get(key) ?? new Set() + set.add(fn) + listeners.set(key, set) + return () => { set.delete(fn) } + }, + } + faces.set(key, face) + } + return face + }, + set: (key: string, value: unknown) => { + values.set(key, value) + for (const fn of [...(listeners.get(key) ?? [])]) fn() + }, + } + Object.assign(this, overrides) + } + + /** @returns the fixture conversation snapshot (useSession read side). */ + getSnapshot(): ConversationSnapshot { + return this.store.getSnapshot() + } + + /** + * Subscribe to fixture snapshot changes. + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + return this.store.subscribe(fn) + } + + /** + * Fail-loud stub; supply `prompt` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + prompt(): never { + throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`) + } + + /** + * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + cancel(): never { + throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`) + } + + /** + * Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + loadOlder(): never { + throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) + } +} + +/** One live test session: fixture-derived stores plus its minted scope state. */ +interface SessionRecord { + summary: SessionSummary + snapshot: SnapshotStore + session: FixtureSession + scope: Context | undefined + scopeFiber: { dispose(): Promise } | undefined + /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ + provideInfo: SessionProvideInfo | undefined +} + +/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */ +export interface TestSessionBinding { + readonly sessionId: SessionId + readonly session: FixtureSession + readonly ctx: Context +} + +/** + * Sessions test double behind the renderer host and feature injects: owns the + * list/current observable, the standard-props provide channel (the runtime's + * `useSession` contribution included), scope minting through the production + * `createScope`, and the session behavior face supplied per fixture. + * + * Implements the same ISessions face features receive as `ctx.sessions`, so + * a production face change breaks this double at compile time; the extra + * members (add/updateSnapshot/setCurrent/remove/behavior/calls and the + * legacy provideInfo/maybeProvideInfo lookups) are bench-only surface. + */ +export class TestSessions implements ISessions { + /** The useSessions standard feed (list rows + current selection). */ + readonly list: SnapshotStore + /** + * Atomic current-session provide projection (production SessionsService + * mirror): selection changes and provider-roster changes publish through + * this one source — the member the SlotsService host face hands the + * renderer's SessionProvider. + */ + readonly currentProvideInfo: HostObservable + private readonly records = new Map() + /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */ + private readonly channel: SessionProvideChannel + + /** Calls observed on the service-level face (open/clear), newest last. */ + readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = [] + + /** + * @param stabilize - the owning runtime's act wrapper. + * @param rootCtx - the runtime's Cordis root; scope fibers mount under it. + */ + constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { + this.list = createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'ready', + }) + this.channel = new SessionProvideChannel({ + rebuildBundles: () => { + for (const record of this.records.values()) { + if (record.provideInfo !== undefined) { + record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record)) + } + } + }, + resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), + }) + this.currentProvideInfo = this.channel.currentProvideInfo + // The projection follows every current write, as in production. + this.list.subscribe(() => { this.channel.publishCurrent() }) + } + + /** + * Add a session from a fixture and (by default) make it current. + * @param fixture - identity + snapshot/summary overrides + behavior face. + * @param opts - pass `current: false` to add without selecting. + * @returns the stable session id (branded view of `fixture.id`). + */ + async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise { + const id = fixture.id as SessionId + if (this.records.has(id)) throw new Error(`test session "${id}" already added`) + const summary: SessionSummary = { + id, + displayTitle: fixture.id, + running: false, + blank: false, + updatedAt: this.records.size + 1, + ...fixture.summary, + } + const snapshot = createSnapshotStore({ + ...conversationSnapshot(id), + ...fixture.snapshot, + }) + this.records.set(id, { + summary, + snapshot, + session: new FixtureSession(id, snapshot, fixture.session ?? {}), + scope: undefined, + scopeFiber: undefined, + provideInfo: undefined, + }) + await this.stabilize(() => { + this.list.update((draft) => { + draft.ids.push(id) + draft.byId[id] = summary + if (opts?.current !== false) draft.current = id + }) + }) + return id + } + + /** + * Update a session's conversation snapshot through an immer draft (the + * live-stream stand-in: components subscribed via useSession re-render). + * @param id - session id. + * @param mutate - draft mutator. + */ + async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise { + const record = this.require(id) + await this.stabilize(() => { record.snapshot.update(mutate) }) + } + + /** + * Switch the current selection (undefined = the no-session empty state). + * @param id - session id to select, or undefined to clear. + */ + async setCurrent(id: string | undefined): Promise { + if (id !== undefined) this.require(id) + await this.stabilize(() => { + this.list.update((draft) => { draft.current = id as SessionId | undefined }) + }) + } + + /** + * Remove a session: list row, scope fiber, and per-session store instances + * (with persisted state) die together — the same single lifecycle axis the + * production SessionsService drives on session death, minus staging. + * @param id - session id. + */ + async remove(id: string): Promise { + const record = this.require(id) + this.records.delete(id as SessionId) + await this.stabilize(async () => { + this.list.update((draft) => { + draft.ids = draft.ids.filter(existing => existing !== id) + const { [id as SessionId]: _dead, ...rest } = draft.byId + draft.byId = rest + if (draft.current === id) draft.current = undefined + }) + if (record.scopeFiber !== undefined) await record.scopeFiber.dispose() + this.rootCtx.get('slots')?.pruneStoreScope(id) + }) + } + + /** + * Register a per-session standard-props provider (production `provide` + * contract: hooks become `use` selector hooks on the render side, + * props spread verbatim; duplicate names fail loud at materialization). + * @param descriptor - static member roster plus per-session resolver. + * @returns disposer removing the provider. + */ + provide(descriptor: SessionProvideDescriptor): () => void { + return this.channel.provide(descriptor) + } + + /** + * Resolve the definite per-session standard-props bundle (host face member). + * @param id - session id. + * @returns the identity-stable bundle, or undefined for unknown sessions. + */ + provideInfo(id: string): SessionProvideInfo | undefined { + const record = this.records.get(id as SessionId) + if (record === undefined) return undefined + record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record)) + return record.provideInfo + } + + /** + * Resolve the current-session-optional standard kit (host face member): + * unknown or absent ids return the static no-session projection. + * @param id - current session id, when selected. + * @returns a definite or no-session provide bundle. + */ + maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo + } + + /** + * Resolve (mint on first touch) the session-scoped Cordis context through + * the production `createScope`, so real `scopeOf`/scope-addressed services + * resolve it. + * @param id - session id. + * @returns the scoped context, or undefined for unknown sessions. + */ + scope(id: string): Context | undefined { + const record = this.records.get(id as SessionId) + if (record === undefined) return undefined + if (record.scope === undefined) { + const handle = createScope(this.rootCtx, id as SessionId) + record.scope = handle.ctx + record.scopeFiber = handle.fiber + } + return record.scope + } + + /** + * Session assembly binding (inject factories and provide resolvers receive it). + * @param id - session id. + * @returns sessionId + behavior face + scoped ctx, or undefined when unknown. + */ + binding(id: string): TestSessionBinding | undefined { + const record = this.records.get(id as SessionId) + if (record === undefined) return undefined + return this.bindingOf(id as SessionId, record) + } + + /** + * Read the session scope tag off a context (service-method seam mirror). + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined { + return scopeOf(ctx) + } + + /** + * Resolve the scoped session face off a context (production `sessionOf` + * mirror). + * @param ctx - any client context. + * @returns the fixture session face, or undefined off-scope. + */ + sessionOf(ctx: Context): SessionFace | undefined { + const id = scopeOf(ctx) + if (id === undefined) return undefined + return this.records.get(id)?.session + } + + /** + * Service-level selection call (recorded, then applied to the list store + * synchronously — inject callbacks call this outside any act window; the + * store notify is microtask-batched so the next stabilized step observes it). + * @param id - session id. + */ + open(id: SessionId): void { + this.calls.push({ method: 'open', args: [id] }) + this.require(id) + this.list.update((draft) => { draft.current = id }) + } + + /** Clear the current selection (recorded; the production no-session flow). */ + clear(): void { + this.calls.push({ method: 'clear', args: [] }) + this.list.update((draft) => { draft.current = undefined }) + } + + /** + * The session face of a fixture (typed view for assertions; fixture + * behavior methods are grafted onto it). + * @param id - session id. + * @returns the FixtureSession the binding and provide channel carry. + */ + behavior(id: string): FixtureSession { + return this.require(id).session + } + + /** Dispose minted scope fibers (runtime dispose path). */ + async disposeScopes(): Promise { + for (const record of this.records.values()) { + if (record.scopeFiber !== undefined) { + await record.scopeFiber.dispose() + record.scope = undefined + record.scopeFiber = undefined + } + } + } + + private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding { + const ctx = this.scope(id) + /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope + * always resolves; kept so a future caller cannot mint a ctx-less binding. */ + if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`) + return { sessionId: id, session: record.session, ctx } + } + + private require(id: string): SessionRecord { + const record = this.records.get(id as SessionId) + if (record === undefined) throw new Error(`test session "${id}" is not added`) + return record + } +} diff --git a/packages/client/test-runtime/src/snapshot.ts b/packages/client/test-runtime/src/snapshot.ts new file mode 100644 index 0000000000..0165676cdf --- /dev/null +++ b/packages/client/test-runtime/src/snapshot.ts @@ -0,0 +1,89 @@ +/** + * DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap` + * files structural. Two normalizations, both on a clone (the live DOM is + * untouched, so class/tag queries keep working): + * + * - CSS-module scoped class names (`_frame_334d2d`, this repo's + * `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so + * CSS edits do not churn snapshots. + * - `` internals collapse to a `data-content` fingerprint on the svg + * element: path geometry is print noise, but the fingerprint still flips + * when an icon's artwork actually changes. + */ +import { expect } from 'vitest' +import type { SnapshotSerializer } from 'vitest' + +/** One scoped class token: `__` (local may itself contain underscores). */ +const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/ + +/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */ +function normalizeClassValue(value: string): string { + return value + .split(/\s+/) + .filter(token => token !== '') + .map(token => token.replace(SCOPED_CLASS, '$1')) + .join(' ') +} + +/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */ +function fingerprint(markup: string): string { + let hash = 0x811c9dc5 + for (let i = 0; i < markup.length; i++) { + hash ^= markup.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +/** svg elements of a subtree, the root included when it is one. */ +function svgsOf(root: Element): Element[] { + const svgs: Element[] = [...root.querySelectorAll('svg')] + if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root) + return svgs +} + +/** Whether serializing this subtree needs a normalized clone. */ +function needsNormalization(root: Element): boolean { + const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => { + const value = el.getAttribute('class') + return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token)) + }) + return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0) +} + +/** + * The serializer plugin. Matches DOM elements whose subtree carries a scoped + * class or svg internals; serializes a normalized clone, which no longer + * matches, so printing falls through to the built-in DOM element serializer. + */ +export const domSnapshotSerializer: SnapshotSerializer = { + test(value: unknown): boolean { + return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value) + }, + serialize(value, config, indentation, depth, refs, printer): string { + const clone = (value as Element).cloneNode(true) as Element + for (const el of [clone, ...clone.querySelectorAll('[class]')]) { + const raw = el.getAttribute('class') + if (raw !== null) el.setAttribute('class', normalizeClassValue(raw)) + } + for (const svg of svgsOf(clone)) { + if (svg.childNodes.length === 0) continue + svg.setAttribute('data-content', fingerprint(svg.innerHTML)) + svg.replaceChildren() + } + return printer(clone, config, indentation, depth, refs) + }, +} + +let registered = false + +/** + * Register {@link domSnapshotSerializer} with vitest's expect (idempotent). + * SlotTestRuntime.create() calls this; specs that snapshot DOM outside the + * runtime import and call it themselves. + */ +export function registerDomSnapshotSerializer(): void { + if (registered) return + registered = true + expect.addSnapshotSerializer(domSnapshotSerializer) +} diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts new file mode 100644 index 0000000000..32c9bff36e --- /dev/null +++ b/packages/client/test-runtime/src/workspaces.ts @@ -0,0 +1,147 @@ +/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { workspaceListState } from './fixtures.ts' +import type { Stabilizer } from './fixtures.ts' + +/** + * Workspaces test double. Implements the same IWorkspaces face features + * receive as `ctx.workspaces`, so a production face change breaks this + * double at compile time. Every action records into {@link + * TestWorkspaces.calls}; defaults are inert echoes — feature tests needing + * richer behavior replace them via {@link TestWorkspaces.stub}. + */ +export class TestWorkspaces implements IWorkspaces { + /** The useWorkspaces standard feed. */ + readonly list: SnapshotStore + + /** Calls observed on the action face, newest last. */ + readonly calls: { method: string; args: unknown[] }[] = [] + + /** Replaceable action seat: feature tests may stub richer behavior. */ + private readonly stubs = new Map unknown>() + + /** + * @param stabilize - the owning runtime's act wrapper. + */ + constructor(private readonly stabilize: Stabilizer) { + this.list = createSnapshotStore(workspaceListState()) + } + + /** + * Update the workspace list state through an immer draft. + * @param mutate - draft mutator. + */ + async update(mutate: (draft: WorkspaceListState) => void): Promise { + await this.stabilize(() => { this.list.update(mutate) }) + } + + /** + * Replace an action's behavior (the recorded call is still appended first). + * @param method - action name (e.g. 'connectWorkspace'). + * @param impl - replacement behavior. + */ + stub(method: string, impl: (...args: unknown[]) => unknown): void { + this.stubs.set(method, impl) + } + + /** + * Connect a workspace to its reusable/new blank session (recorded). The + * default resolves the workspace id back as the session id; stub for + * cross-session flows. + * @param workspaceId - target workspace. + * @returns the connected session id. + */ + async connectWorkspace(workspaceId: WorkspaceId): Promise { + this.calls.push({ method: 'connectWorkspace', args: [workspaceId] }) + const stub = this.stubs.get('connectWorkspace') + if (stub !== undefined) return await (stub(workspaceId) as Promise) + return `session-of-${workspaceId}` as SessionId + } + + /** + * New-session flow (recorded; stubbed behavior runs when installed). + * @param workspaceId - optional explicit workspace target. + */ + startSession(workspaceId?: WorkspaceId): void { + this.calls.push({ method: 'startSession', args: [workspaceId] }) + this.stubs.get('startSession')?.(workspaceId) + } + + /** + * Create a Workspace (recorded). The default echoes a view derived from + * the input; stub for failure or list-coupled flows. + * @param input - exactly one Host create spelling. + * @returns the created Workspace view. + */ + async create(input: { name: string } | { path: string }): Promise { + this.calls.push({ method: 'create', args: [input] }) + const stub = this.stubs.get('create') + if (stub !== undefined) return await (stub(input) as Promise) + const title = 'name' in input ? input.name : input.path + return { + workspaceId: `ws-${title}` as WorkspaceId, + title, + path: 'path' in input ? input.path : `/${input.name}`, + sessionIds: [], + } as unknown as WorkspaceView + } + + /** + * Open a path with the host OS default application (recorded; default no-op). + * @param path - host-resolvable path. + */ + async openPath(path: string): Promise { + this.calls.push({ method: 'openPath', args: [path] }) + await (this.stubs.get('openPath')?.(path) as Promise | undefined) + } + + /** + * Directory picker (recorded). The default cancels (null); stub to select. + * @returns the picked path, or null. + */ + async pickDirectory(): Promise { + this.calls.push({ method: 'pickDirectory', args: [] }) + const stub = this.stubs.get('pickDirectory') + if (stub !== undefined) return await (stub() as Promise) + return null + } + + /** + * Rename a Workspace (recorded). The default echoes a minimal view. + * @param workspaceId - target workspace. + * @param title - new title. + * @returns the updated view. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise { + this.calls.push({ method: 'rename', args: [workspaceId, title] }) + const stub = this.stubs.get('rename') + if (stub !== undefined) return await (stub(workspaceId, title) as Promise) + return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView + } + + /** + * Delete a Workspace (recorded; default no-op). + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + this.calls.push({ method: 'delete', args: [workspaceId] }) + await (this.stubs.get('delete')?.(workspaceId) as Promise | undefined) + } + + /** + * Move an accounted session (recorded). The default echoes a minimal view. + * @param workspaceId - target workspace. + * @param sessionId - session to move. + * @param beforeSessionId - anchor; omitted appends. + * @returns the updated view. + */ + async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise { + this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] }) + const stub = this.stubs.get('insertSessionBefore') + if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise) + return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView + } +} diff --git a/packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap b/packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap new file mode 100644 index 0000000000..3b46019a43 --- /dev/null +++ b/packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = ` +
+
+ + styled + + +
+
+`; + +exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = ` +
+
+ +
+
+`; diff --git a/packages/client/test-runtime/tests/invariant.spec.ts b/packages/client/test-runtime/tests/invariant.spec.ts new file mode 100644 index 0000000000..837559ec81 --- /dev/null +++ b/packages/client/test-runtime/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx new file mode 100644 index 0000000000..e63fcc3821 --- /dev/null +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -0,0 +1,546 @@ +// @vitest-environment jsdom +/** + * SlotTestRuntime behavior: root declaration + rendering, session + * add/update/switch/remove through the real renderer, shared store identity + * and scope pruning, feature mount/dispose cascade, and runtime disposal + * idempotence. All through the production SlotsService + createSlotRenderer + * stack — this suite is the fixture the migrated feature specs rely on. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { defineStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } } + 'trt.chat': { kind: 'single'; scope: 'session' } + 'trt.rows': { kind: 'list'; scope: 'root' } + } +} + +afterEach(cleanup) + +type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'> + +/** Root frame declaring all three suite slots (render sites for each kind). */ +function Frame({ renderSlot, SessionProvider }: FrameProps) { + return ( + <> + {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: no panel })} + no session}> + {() => renderSlot('trt.chat', {})} + + {renderSlot('trt.rows', {})} + + ) +} + +const CHILDREN = { + 'trt.panel': { kind: 'single', scope: 'root' }, + 'trt.chat': { kind: 'single', scope: 'session' }, + 'trt.rows': { kind: 'list', scope: 'root' }, +} as const + +async function runtimeWithFrame() { + const runtime = await SlotTestRuntime.create() + await runtime.root.declare(CHILDREN, Frame) + return runtime +} + +describe('root declaration and rendering', () => { + it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => { + const runtime = await runtimeWithFrame() + const view = runtime.renderRoot() + expect(view.container.textContent).toContain('no panel') + + let dispose = (): void => {} + await runtime.flush() // no-op guard: flush outside mutations is safe + await (async () => { + dispose = runtime.slots.register( + { name: 'trt.panel' }, + ({ label }: { label?: string }) => panel:{label}) + await runtime.flush() + })() + expect(view.container.textContent).toContain('panel:from-owner') + dispose() + await runtime.flush() + expect(view.container.textContent).toContain('no panel') + await runtime.dispose() + }) + + it('fails loud when rendering with no root declaration (production boot-order check)', async () => { + const runtime = await SlotTestRuntime.create() + expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/) + await runtime.dispose() + }) +}) + +describe('sessions', () => { + it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => { + const runtime = await runtimeWithFrame() + runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => { + const running = props.useSession(s => s.running) + return chat:{props.sessionId}:{String(running)} + }) + const view = runtime.renderRoot() + expect(view.container.textContent).toContain('no session') + + await runtime.sessions.add({ id: 's1' }) + expect(view.container.textContent).toContain('chat:s1:false') + + await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true }) + expect(view.container.textContent).toContain('chat:s1:true') + + await runtime.sessions.add({ id: 's2' }) // becomes current by default + expect(view.container.textContent).toContain('chat:s2:false') + + await runtime.sessions.setCurrent(undefined) + expect(view.container.textContent).toContain('no session') + await runtime.sessions.setCurrent('s1') + expect(view.container.textContent).toContain('chat:s1:true') + await runtime.dispose() + }) + + it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => { + const runtime = await runtimeWithFrame() + await runtime.sessions.add({ id: 's1' }) + await runtime.sessions.add({ id: 's2' }, { current: false }) + expect(runtime.sessions.list.getSnapshot().current).toBe('s1') + expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2']) + await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/) + await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/) + await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/) + await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/) + expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/) + await runtime.dispose() + }) + + it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => { + const runtime = await runtimeWithFrame() + const prompt = vi.fn() + await runtime.sessions.add({ id: 's1', session: { prompt } }) + + expect(runtime.sessions.provideInfo('ghost')).toBeUndefined() + expect(runtime.sessions.scope('ghost')).toBeUndefined() + expect(runtime.sessions.binding('ghost')).toBeUndefined() + + const scope = runtime.sessions.scope('s1')! + expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session + expect(runtime.sessions.scopeOf(scope)).toBe('s1') + expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined() + // sessionOf resolves the behavior face off the scope tag. + expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1')) + expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined() + + const binding = runtime.sessions.binding('s1')! + expect(binding.sessionId).toBe('s1') + expect(binding.ctx).toBe(scope) + ;(binding.session as { prompt: () => void }).prompt() + expect(prompt).toHaveBeenCalledOnce() + expect(runtime.sessions.behavior('s1')).toBe(binding.session) + // The binding's session doubles as the conversation observable face. + expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1') + + // A scoped service resolves through the scope ctx (scope-addressed pattern). + runtime.provide('probe', { hello: 'world' }) + expect(scope.get('probe')).toEqual({ hello: 'world' }) + await runtime.dispose() + }) + + it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => { + const runtime = await runtimeWithFrame() + await runtime.sessions.add({ id: 's1' }) + + const info = runtime.sessions.provideInfo('s1')! + expect(info.sessionId).toBe('s1') + expect(info.hooks['session']).toBeDefined() // the built-in useSession source + expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable + + // A feature provider (the ui-conversation input pattern): declared names + // materialize per session and land in the no-session roster as undefined. + const off = runtime.sessions.provide({ + hooks: ['probe'], + props: ['probeActions'], + resolve: binding => ({ + hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } }, + props: { probeActions: { poke: () => {} } }, + }), + }) + const enriched = runtime.sessions.provideInfo('s1')! + expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1') + expect(enriched.props['probeActions']).toBeDefined() + const maybe = runtime.sessions.maybeProvideInfo(undefined) + expect(maybe.sessionId).toBeUndefined() + expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe']) + expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1')) + expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined() + + // Misdeclared providers fail loud AT REGISTRATION (the production + // channel rebuilds live bundles eagerly and rolls the roster back): + // missing hook, missing prop, duplicate hook, duplicate prop. + expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) })) + .toThrow(/missing hook "void"/) + expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) })) + .toThrow(/missing prop "void"/) + expect(() => runtime.sessions.provide({ + hooks: ['session'], + resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }), + })).toThrow(/duplicate hook "session"/) + const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) }) + expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) })) + .toThrow(/duplicate prop "twice"/) + propA() + // The rejected registrations rolled back: the roster still materializes. + expect(runtime.sessions.provideInfo('s1')).toBeDefined() + off() + off() // disposer is idempotent + expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session']) + await runtime.dispose() + }) + + it('records service-face calls; open() moves the selection and clear() empties it', async () => { + const runtime = await runtimeWithFrame() + await runtime.sessions.add({ id: 's1' }) + await runtime.sessions.add({ id: 's2' }) + runtime.sessions.open('s1' as SessionId) + await runtime.flush() + expect(runtime.sessions.list.getSnapshot().current).toBe('s1') + runtime.sessions.clear() + await runtime.flush() + expect(runtime.sessions.list.getSnapshot().current).toBeUndefined() + expect(runtime.sessions.calls).toEqual([ + { method: 'open', args: ['s1'] }, + { method: 'clear', args: [] }, + ]) + await runtime.dispose() + }) +}) + +describe('stores', () => { + const createSuiteStore = () => defineStore({ + init: () => ({ note: '' }), + persist: 'trt.store', + actions: { setNote: (d, note: string) => { d.note = note } }, + }) + + it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => { + const runtime = await runtimeWithFrame() + const handle = createSuiteStore() + runtime.slots.register( + { name: 'trt.chat', store: handle }, + (props: SessionStandardProps & { useStore: (sel: (s: { note: string }) => S) => S }) => + note:{props.useStore(s => s.note)}) + const view = runtime.renderRoot() + await runtime.sessions.add({ id: 's1' }) + + expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/) + const store = runtime.storeOf('trt.chat', 's1') + await runtime.flush() + ;(store.actions['setNote'] as (note: string) => void)('hello') + await runtime.flush() + expect(view.container.textContent).toContain('note:hello') + expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key + + await runtime.sessions.add({ id: 's2' }) + const other = runtime.storeOf('trt.chat', 's2') + expect(other).not.toBe(store) + expect(other.getSnapshot()).toEqual({ note: '' }) + await runtime.dispose() + }) + + it('storeOf guards: before renderRoot, and for storeless entries', async () => { + const runtime = await runtimeWithFrame() + runtime.slots.register({ name: 'trt.panel' }, () => null) + expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/) + runtime.renderRoot() + expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/) + await runtime.dispose() + }) + + it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => { + const runtime = await runtimeWithFrame() + const handle = createSuiteStore() + runtime.slots.register({ name: 'trt.chat', store: handle }, () => null) + runtime.renderRoot() + await runtime.sessions.add({ id: 's1' }) + + const doomed = runtime.storeOf('trt.chat', 's1') + ;(doomed.actions['setNote'] as (note: string) => void)('buried') + expect(localStorage.getItem('trt.store.s1')).not.toBeNull() + + await runtime.sessions.remove('s1') + expect(localStorage.getItem('trt.store.s1')).toBeNull() + expect(runtime.sessions.list.getSnapshot().ids).toEqual([]) + expect(runtime.sessions.provideInfo('s1')).toBeUndefined() + + await runtime.sessions.add({ id: 's1' }) + const reborn = runtime.storeOf('trt.chat', 's1') + expect(reborn).not.toBe(doomed) + expect(reborn.getSnapshot()).toEqual({ note: '' }) + await runtime.dispose() + }) + + it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => { + const runtime = await runtimeWithFrame() + await runtime.sessions.add({ id: 's1' }) + await runtime.sessions.add({ id: 's2' }, { current: false }) + const scope = runtime.sessions.scope('s1')! + await runtime.sessions.remove('s2') + expect(runtime.sessions.list.getSnapshot().current).toBe('s1') + await runtime.sessions.remove('s1') + expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid + expect(runtime.sessions.list.getSnapshot().current).toBeUndefined() + await runtime.dispose() + }) +}) + +describe('workspaces', () => { + it('feeds useWorkspaces and records/stubs intent actions', async () => { + const runtime = await runtimeWithFrame() + runtime.slots.register( + { name: 'trt.panel' }, + (props: { useWorkspaces: (sel: (s: { phase: string }) => S) => S }) => + ws:{props.useWorkspaces(s => s.phase)}) + const view = runtime.renderRoot() + expect(view.container.textContent).toContain('ws:ready') + + await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) + expect(view.container.textContent).toContain('ws:pending') + + runtime.workspaces.startSession('w1' as WorkspaceId) + await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2') + expect(runtime.workspaces.calls).toEqual([ + { method: 'startSession', args: ['w1'] }, + { method: 'connectWorkspace', args: ['w2'] }, + ]) + const stub = vi.fn(() => Promise.resolve('other' as never)) + runtime.workspaces.stub('connectWorkspace', stub) + await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other') + expect(stub).toHaveBeenCalledOnce() + await runtime.dispose() + }) +}) + +describe('feature mount and disposal', () => { + it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => { + const runtime = await runtimeWithFrame() + runtime.provide('layout', { openDetails: vi.fn() }) + const feature = await runtime.mount({ + inject: ['slots', 'layout'], + apply: (ctx: typeof runtime.ctx) => { + ctx.provide('feature-service', { ok: true }) + ctx.slots.register({ + name: 'trt.rows', + id: 'row-1', + children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } }, + } as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) => +
{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}
) as never) + }, + }) + const view = runtime.renderRoot() + expect(view.getByTestId('row')).toBeTruthy() + expect(runtime.ctx.get('feature-service')).toEqual({ ok: true }) + expect(runtime.slots.entries('trt.rows')).toHaveLength(1) + + await feature.dispose() + await feature.dispose() // idempotent + expect(runtime.slots.entries('trt.rows')).toHaveLength(0) + expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined() + expect(runtime.ctx.get('feature-service')).toBeUndefined() + expect(view.queryByTestId('row')).toBeNull() + await runtime.dispose() + }) + + it('mount fails loud on missing services instead of suspending forever', async () => { + const runtime = await runtimeWithFrame() + await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} })) + .rejects.toThrow(/missing service\(s\) absent-service/) + await runtime.dispose() + }) + + it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => { + const runtime = await runtimeWithFrame() + const feature = await runtime.mount({ + inject: ['slots'], + apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => p) }, + }) + const view = runtime.renderRoot() + expect(view.container.textContent).toContain('p') + localStorage.setItem('trt.leftover', 'x') + + await runtime.dispose() + expect(view.container.innerHTML).toBe('') + expect(feature.fiber.uid).toBeNull() + expect(localStorage.getItem('trt.leftover')).toBeNull() + await runtime.dispose() // idempotent + await expect(runtime.dispose()).resolves.toBeUndefined() + }) +}) + +describe('single-slot mounting (declare + renderSlot)', () => { + it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } }) + runtime.slots.register( + { name: 'trt.panel' }, + ({ label }: { label?: string }) => {label ?? 'none'}) + const slot = runtime.renderSlot('trt.panel', { label: 'first' }) + expect(slot.container.getAttribute('data-slot')).toBe('trt.panel') + expect(slot.view.getByTestId('panel').textContent).toBe('first') + + const panel = slot.view.getByTestId('panel') + slot.update({ label: 'second' }) + expect(slot.view.getByTestId('panel').textContent).toBe('second') + // In-place re-render: the element identity survived the owner flip. + expect(slot.view.getByTestId('panel')).toBe(panel) + await runtime.dispose() + }) + + it('views sibling slots of one tree separately and rejects undeclared keys', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.declare({ + 'trt.panel': { kind: 'single', scope: 'root' }, + 'trt.rows': { kind: 'list', scope: 'root' }, + }) + runtime.slots.register({ name: 'trt.panel' }, () => panel) + runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => row) + const panel = runtime.renderSlot('trt.panel', {}) + const rows = runtime.renderSlot('trt.rows', {}) + expect(panel.container.textContent).toBe('panel') + expect(rows.container.textContent).toBe('row') + expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/) + await runtime.dispose() + }) + + it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } }) + runtime.slots.register({ name: 'trt.panel' }, () => ( +
+ styled + +
+ )) + const slot = runtime.renderSlot('trt.panel', {}) + expect(slot.container).toMatchSnapshot() + // The serializer works on a clone: the live DOM keeps hashes and paths. + expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain') + expect(slot.container.querySelector('svg path')).not.toBeNull() + await runtime.dispose() + }) +}) + +describe('fixture session face', () => { + it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: 's1' }) + const bare = runtime.sessions.behavior('s1') + expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) + expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) + expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) + await runtime.dispose() + }) + + it('projections faces are identity-stable per key, read absent, and notify on set', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: 's1' }) + const session = runtime.sessions.behavior('s1') + const face = session.projections.faceOf('todos') + expect(session.projections.faceOf('todos')).toBe(face) + expect(face.getSnapshot()).toBeUndefined() + const seen: unknown[] = [] + const off = face.subscribe(() => { seen.push(face.getSnapshot()) }) + session.projections.set('todos', [1, 2]) + expect(seen).toEqual([[1, 2]]) + off() + session.projections.set('todos', [3]) + expect(seen).toEqual([[1, 2]]) // unsubscribed + // A never-subscribed key sets without listeners (the empty-notify arm). + session.projections.set('untouched', 1) + // The provide bundle hands the same store to the render side. + const info = runtime.sessions.provideInfo('s1')! + expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3]) + // A roster change rebuilds the ALREADY-materialized bundle eagerly + // (production channel semantics: mounted entries must see the provider) + // and skips never-materialized records (they pick the roster up lazily). + await runtime.sessions.add({ id: 's-lazy' }, { current: false }) + const offProbe = runtime.sessions.provide({ + hooks: ['probe2'], + resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }), + }) + const rebuilt = runtime.sessions.provideInfo('s1')! + expect(rebuilt).not.toBe(info) + expect(rebuilt.hooks['probe2']).toBeDefined() + offProbe() + await runtime.dispose() + }) +}) + +describe('workspaces action face', () => { + it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { + const runtime = await SlotTestRuntime.create() + const ws = runtime.workspaces + const created = await ws.create({ name: 'alpha' }) + expect(created.title).toBe('alpha') + const registered = await ws.create({ path: '/tmp/beta' }) + expect(registered.path).toBe('/tmp/beta') + await expect(ws.pickDirectory()).resolves.toBeNull() + const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed') + expect(renamed.title).toBe('Renamed') + await ws.delete('w1' as WorkspaceId) + await ws.openPath('/proj/file.ts') + const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) + expect(moved.sessionIds).toEqual(['s1']) + expect(ws.calls.map(c => c.method)).toEqual( + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore']) + + ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) + ws.stub('pickDirectory', () => Promise.resolve('/picked')) + ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) + ws.stub('delete', () => Promise.resolve()) + ws.stub('openPath', () => Promise.resolve()) + ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) + expect((await ws.create({ name: 'y' })).title).toBe('X') + await expect(ws.pickDirectory()).resolves.toBe('/picked') + expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') + await ws.delete('w1' as WorkspaceId) + await ws.openPath('/other') + expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) + await runtime.dispose() + }) +}) + +describe('single-slot mounting edge arms', () => { + it('renderSlot fails loud after dispose and after an external unmount', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } }) + runtime.slots.register({ name: 'trt.panel' }, () => p) + runtime.renderSlot('trt.panel', {}) + // RTL cleanup empties the mounted tree behind the runtime's back: the + // wrapper lookup names the state instead of returning a dead container. + cleanup() + expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/) + await runtime.dispose() + // After dispose the root registration is gone: the production boot-order + // check fires before any wrapper lookup. + expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/) + }) + + it('serializes childless svg untouched next to scoped classes', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } }) + runtime.slots.register({ name: 'trt.panel' }, () => ( +
+ +
+ )) + const slot = runtime.renderSlot('trt.panel', {}) + expect(slot.container).toMatchSnapshot() + await runtime.dispose() + }) +}) diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json new file mode 100644 index 0000000000..3e8a8561f8 --- /dev/null +++ b/packages/client/test-runtime/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../runtime" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6aa2b57331..420e318e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -924,6 +924,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/test-runtime: + dependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-command: dependencies: clsx: @@ -1250,6 +1290,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -1467,6 +1510,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -15933,6 +15979,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.9.3 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-jsonrpc@5.0.1: {} vscode-jsonrpc@9.0.1: {} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b5ada214a3..da61bcc77e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' }, + 'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 460e298089..80db30e3eb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -118,6 +118,7 @@ "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"], + "@deepseek-ai/dsh-client-test-runtime": ["./packages/client/test-runtime/src"], "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 9f67ab4af7..8caa5531a1 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -34,6 +34,7 @@ { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, { "path": "./packages/client/runtime" }, + { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, From a3f9a4d3eedf288132869b6397a36a891c885a0d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:04:39 +0800 Subject: [PATCH 55/61] test(client): migrate the ui-conversation and web benches onto SlotTestRuntime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five ui-conversation machinery specs (apply-inject, chat-apply, chat-toolview-slot, service-orchestration, selection-survival — now .tsx) and the web app/app-shell specs assemble through the runtime instead of hand-built Context + SlotsService + fake-session scaffolding per suite. Session behavior mocks are typed against ISession, so incomplete fakes fail at compile time. --- .../tests/apply-inject.spec.tsx | 270 +++++++++--------- .../ui-conversation/tests/chat-apply.spec.tsx | 93 ++---- .../tests/chat-toolview-slot.spec.tsx | 225 ++++----------- .../tests/selection-survival.spec.ts | 149 ---------- .../tests/selection-survival.spec.tsx | 109 +++++++ .../tests/service-orchestration.spec.ts | 65 +++-- packages/client/web/package.json | 1 + packages/client/web/tests/app-shell.spec.tsx | 61 ++++ packages/client/web/tests/app.spec.tsx | 65 +++++ 9 files changed, 483 insertions(+), 555 deletions(-) delete mode 100644 packages/client/ui-conversation/tests/selection-survival.spec.ts create mode 100644 packages/client/ui-conversation/tests/selection-survival.spec.tsx create mode 100644 packages/client/web/tests/app-shell.spec.tsx create mode 100644 packages/client/web/tests/app.spec.tsx diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3320c79d3a..416f3fa4ef 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -1,144 +1,73 @@ // @vitest-environment jsdom // apply inject factories exercised end to end against the terminal thin -// shape: the conversation surface (views triple, send choreography incl. -// optimistic clear + failure restore THROUGH the declared store actions, -// openDetails = select action + layout orchestration, sessions.open -// navigation), and the closeDetails details surface. Complements -// chat-apply.spec.tsx (registration) -// and selection-survival.spec.ts (store axis). History opening is NOT an -// inject concern anymore — the runtime sessions service opens on watch -// (sessions-service.spec.ts owns that behavior). +// shape: the strict session surface (views triple, draft mirror), the +// provide-channel input face (machine-sink submit choreography incl. +// optimistic clear + failure restore), the resident surface (selectWorkspace +// draft carrying), the composer-bar stop face, openDetails = select action + +// layout orchestration, and the closeDetails details surface. Complements +// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store +// axis). History opening is NOT an inject concern — the runtime sessions +// service opens on watch (sessions-service.spec.ts owns that behavior). +// +// The inject surfaces are read off the ledger entries deliberately (typed at +// this spec's own contract): these cases pin factory choreography the UI +// guards would mask. Rendering-path acceptance lives in +// chat-toolview-slot.spec.tsx. -import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { - SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react' +import { describe, expect, it, vi } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createChatStore } from '../src/client/stores.ts' -afterEach(cleanup) - const ROOT = 'root-1' as SessionId type ChatInstance = ReturnType['create']> type ChatActions = ChatInstance['actions'] -const SCOPE_TAG: symbol = (() => { - const recorded: (string | symbol)[] = [] - const spy = new Proxy(new Context(), { - get(target, prop, receiver) { - recorded.push(prop) - // Reflect.get is typed any; the probe only records property names. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver) - }, - }) - void scopeOf(spy) - const symbol = recorded.find((p): p is symbol => typeof p === 'symbol') - if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read') - return symbol -})() +/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */ +function sessionFakeFor() { + return { + open: vi.fn(() => Promise.resolve()), + loadOlder: vi.fn(() => Promise.resolve()), + prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + } satisfies SessionBehaviorOverrides +} async function bench() { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - - const listStore = createSnapshotStore({ - ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } }, - current: ROOT, - phase: 'ready', + const runtime = await SlotTestRuntime.create() + const sessionFake = sessionFakeFor() + await runtime.sessions.add({ + id: ROOT, + summary: { title: 'R', displayTitle: 'R', cwd: '/proj' }, + session: sessionFake, }) - const sessionFake = { - sessionId: ROOT, - open: vi.fn(() => Promise.resolve()), - loadOlder: vi.fn(() => Promise.resolve()), - prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( - () => Promise.resolve({ ok: true, value: { accepted: true } })), - cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( - () => Promise.resolve({ ok: true, value: { accepted: true } })), - // Observable face (the input machine's queue read face rides it). - getSnapshot: () => ({ queue: [] }), - subscribe: () => () => {}, - } - const scopes = new Map() - const mint = (id: SessionId): Context => { - let scoped = scopes.get(id) - if (scoped === undefined) { - scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) - scopes.set(id, scoped) - } - return scoped - } - type TestProvider = { - resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): { - hooks?: Record - props?: Record - } - } - const providers: TestProvider[] = [] - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } - const sessionsFake = { - list: listStore, - binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), - scope: (id: SessionId) => mint(id), - provideInfo: () => undefined, - currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, - scopeOf, - sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), - open: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - const workspaceStore = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) - const workspacesFake = { - list: workspaceStore, - connectWorkspace: vi.fn(async () => ROOT), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } - ctx.provide('layout', layoutFake) - ctx.provide('locale', { bind: () => (key: string) => key }) + runtime.provide('layout', layoutFake) - // The AppFrame role: the three conversation-package slots must be declared - // by a live entry before apply can contribute into them (the stand-in - // consumes renderSlot to satisfy the declare-means-render check). - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, + // The AppFrame role: the conversation-package slots must be declared by a + // live entry before apply can contribute into them. + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, }, (_p: { renderSlot?: unknown }) => null) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + const feature = await runtime.mount({ inject: [...inject], apply }) - // Reach the render-side entry view (inject + store handle) the way the - // renderer does: through the host face. - let host: SlotRendererHost | undefined - slots.install({ renderRoot: (h) => { host = h; return null } }) - slots.renderSlot('root', {}) - const hostFace = host! - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]! + // The host face (store resolution) exists only inside the installed + // renderer, so materialize it the way the shell does. + runtime.renderRoot() + const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => + runtime.slots.entries(key)[0]! /** Resolve store instance + call the inject the way the outlet would. */ const conversationSurface = (id: SessionId) => { const entry = entryOf('conversation.session') - const instance = hostFace.storeOf(entry, id) as ChatInstance + const instance = runtime.storeOf('conversation.session', id) as ChatInstance const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)( id, instance.actions) return { instance, injected } @@ -154,27 +83,28 @@ async function bench() { /** Same resolution for the chat entry riding the view ring. */ const chatViewSurface = (id: SessionId) => { const entry = entryOf('conversation.view') - const instance = hostFace.storeOf(entry, id) as ChatInstance + const instance = runtime.storeOf('conversation.view', id) as ChatInstance const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)( id, instance.actions) return { instance, injected } } /** Materialize the input provide contribution the way the runtime does. */ const inputSurface = (id: SessionId) => { - const contribution = providers[0]!.resolve(sessionsFake.binding(id)) - const state = contribution.hooks!['input'] as { + const info = runtime.sessions.provideInfo(id)! + const state = info.hooks['input'] as { getSnapshot: () => { draft: string } subscribe: (fn: () => void) => () => void } - const actions = contribution.props!['inputActions'] as { + const actions = info.props['inputActions'] as { setDraft: (text: string) => void submit: (mode?: 'queue' | 'steer') => void } return { state, actions } } return { - ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, - sessionFake, sessionsFake, workspacesFake, layoutFake, mint, + runtime, feature, slots: runtime.slots, entryOf, + conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, + sessionFake, layoutFake, } } @@ -190,6 +120,7 @@ describe('conversation slot inject surface', () => { const chatView = b.chatViewSurface(ROOT) chatView.injected.loadOlder() expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) + await b.runtime.dispose() }) it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { @@ -208,14 +139,14 @@ describe('conversation slot inject surface', () => { await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) + b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit('queue') await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restore fills empty only). - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) + b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit('queue') actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) @@ -229,23 +160,25 @@ describe('conversation slot inject surface', () => { expect(mirrored).toEqual(['mirrored text']) unbind() // Stop failure is swallowed (promptError owns the surface). - b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } }) + b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) b.composerSurface(ROOT).stop() await new Promise(r => setTimeout(r, 0)) expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1) + await b.runtime.dispose() }) - it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => { + it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected - // Unknown session: sessions.scope answers nothing. - ;(b.sessionsFake.scope as unknown) = () => undefined - expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/) - // A scope minted outside the service tree: no conversation service on it. - const foreign = new Context() - ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({}) - expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/) + // Unknown session: the keyboard face's binding resolution answers nothing. + expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/) + // A scope whose service tree lost 'conversation' (the feature fiber + // unloaded while a retained inject closure re-runs): fails loud too. + const stop = injectFn(ROOT).stop + await b.feature.dispose() + expect(() => { stop() }).toThrow(/unavailable through the session scope/) + await b.runtime.dispose() }) it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => { @@ -258,6 +191,7 @@ describe('conversation slot inject surface', () => { // writes land where the skeleton and details read. const conv = b.conversationSurface(ROOT) expect(conv.instance).toBe(instance) + await b.runtime.dispose() }) it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { @@ -265,8 +199,9 @@ describe('conversation slot inject surface', () => { const { injected } = b.chatViewSurface(ROOT) injected.openFile('src/a.ts') await vi.waitFor(() => { - expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) + await b.runtime.dispose() }) it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => { @@ -274,23 +209,73 @@ describe('conversation slot inject surface', () => { const { injected } = b.conversationSurface(ROOT) const resident = b.residentSurface(ROOT) injected.open(ROOT) - expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) // Same-session connect (the picked workspace resolves to this session): // no draft movement, plain re-open. + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) const { state, actions } = b.inputSurface(ROOT) actions.setDraft('carry me') void resident.selectWorkspace('workspace-1' as never) - await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) }) - expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1') + await vi.waitFor(() => { + expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2) + }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] }) expect(state.getSnapshot().draft).toBe('carry me') // Cross-session connect: the draft MOVES — the old machine empties, the // new session's machine receives the text, then navigation lands there. const OTHER = 'other-1' as SessionId - b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER) + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) void resident.selectWorkspace('workspace-2' as never) - await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) }) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) + }) expect(state.getSnapshot().draft).toBe('') expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me') + await b.runtime.dispose() + }) + + it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => { + const b = await bench() + // No-session resident (hero before any session): connect resolves and + // navigation proceeds without any draft choreography. + const noSession = b.residentSurface(undefined) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) + void noSession.selectWorkspace('workspace-0' as never) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) + }) + + // Cross-session connect with an EMPTY draft: no move, no clearing. + const OTHER = 'b9-other' as SessionId + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + const resident = b.residentSurface(ROOT) + const { state } = b.inputSurface(ROOT) + expect(state.getSnapshot().draft).toBe('') + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) + void resident.selectWorkspace('workspace-3' as never) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) + }) + expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('') + + // Connect failure: the rejection propagates to the caller (the view owns + // the rollback) and no further navigation happens. + const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length + b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline'))) + await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline') + expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens) + await b.runtime.dispose() + }) + + it('scopedConversation fails loud when the session resolves no scope', async () => { + const b = await bench() + // The chat-view inject resolves the scoped conversation service at inject + // time: an unlisted session hits the scope() === undefined throw directly. + const entry = b.entryOf('conversation.view') + const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown + expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/) + await b.runtime.dispose() }) it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => { @@ -313,6 +298,7 @@ describe('conversation slot inject surface', () => { off() off2() unsub() + await b.runtime.dispose() }) }) @@ -325,9 +311,9 @@ describe('details inject surface', () => { injected.closeDetails() expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1) // The shared handle: details resolves the SAME instance conversation writes. - const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT) - const details = b.hostFace.storeOf(entry, ROOT) + const conv = b.runtime.storeOf('conversation.session', ROOT) + const details = b.runtime.storeOf('details', ROOT) expect(details).toBe(conv) + await b.runtime.dispose() }) - }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index afafafa3dc..4d6dc99f4a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -1,89 +1,53 @@ // @vitest-environment jsdom // apply wiring: the conversation service provided, the chat view registered // as the first 'conversation.view' ring entry declaring the keyed toolview -// hole, the three slot registrations land against a root entry's children -// declarations (the AppFrame role), the shared store handle rides all session -// entries, and the bash sample mounts through the load-order seam as a keyed -// entry. Full-chain rendering belongs to the machinery spec -// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the -// assembly surface. +// hole, the slot registrations land against a root entry's children +// declarations (the AppFrame role), the shared store handle rides all strict +// session entries, and the bash sample + todo row mount through the +// load-order seam as keyed entries. Full-chain rendering belongs to the +// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec +// stops at the assembly surface. -import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const ROOT = 'root-1' as SessionId const CHILD = 'child-1' as SessionId async function bench() { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) + await runtime.sessions.add( + { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - const listStore = createSnapshotStore({ - ids: [ROOT, CHILD], - byId: { - [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 }, - }, - current: undefined, - phase: 'ready', - }) - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } - const sessionsFake = { - list: listStore, - binding: vi.fn(), - scope: () => undefined, - provideInfo: () => undefined, - currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - provide: vi.fn(() => () => {}), - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - ctx.provide('workspaces', { - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - }) - ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('locale', { bind: () => (key: string) => key }) - - // Declared by ui-layout's root entry in production; a stand-in root - // occupant declares them here so the contributions land (it consumes - // renderSlot to satisfy the declare-means-render check). - const slots = ctx.get('slots') as SlotsService - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, + // Declared by ui-layout's root entry in production; the test root declares + // them here so the contributions land. + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, }, (_p: { renderSlot?: unknown }) => null) - const fiber = ctx.plugin({ inject: [...inject], apply }) - return { ctx, fiber, slots } + const feature = await runtime.mount({ inject: [...inject], apply }) + return { runtime, feature, slots: runtime.slots } } /** First stored entry for a key (inject/store live directly on StoredEntry). */ -function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') { +function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') { return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown } } describe('apply wiring', () => { it('provides the conversation service', async () => { const b = await bench() - await b.fiber.await() - expect(b.ctx.get('conversation')).toBeDefined() + expect(b.runtime.ctx.get('conversation')).toBeDefined() + await b.runtime.dispose() }) it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => { const b = await bench() - await b.fiber.await() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) expect(entries[0]?.options.label).toBe('Chat') @@ -91,11 +55,11 @@ describe('apply wiring', () => { // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' }) + await b.runtime.dispose() }) it('occupies the slots + the ring; session entries share one store handle', async () => { const b = await bench() - await b.fiber.await() const conversation = renderEntryOf(b.slots, 'conversation') const conversationSession = renderEntryOf(b.slots, 'conversation.session') const chatView = renderEntryOf(b.slots, 'conversation.view') @@ -111,21 +75,21 @@ describe('apply wiring', () => { // The hero workspace picker hole rides the conversation entry's children // declaration (the empty-state occupant is gone). expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + await b.runtime.dispose() }) it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - await b.fiber.await() // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + await b.runtime.dispose() }) it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { const b = await bench() - await b.fiber.await() - await b.fiber.dispose() + await b.feature.dispose() expect(b.slots.entries('conversation')).toHaveLength(0) // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. @@ -133,6 +97,7 @@ describe('apply wiring', () => { expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) - expect(b.ctx.get('conversation')).toBeUndefined() + expect(b.runtime.ctx.get('conversation')).toBeUndefined() + await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 784c04c571..3d2e9ea0e6 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // The dissolved tool ring's acceptance chain on the REAL machinery stack: -// cordis Context + SlotsService ledger + the web-react renderer + this -// package's own apply — no outlet twins. Proves the keyed +// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react +// renderer) + this package's own apply — no outlet twins. Proves the keyed // 'conversation.chat.toolview' hole end to end: registered rows dispatch by // entryKey (the bash sample lands through its plugin), unregistered tools // fall back to GenericToolCard at the render site, live registration/unload @@ -10,23 +10,16 @@ // inject: ['slots', 'conversation'] load-order seam suspends on real fiber // semantics until the service (and with it the hole declaration) is present. -import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { - ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' +import { cleanup, fireEvent } from '@testing-library/react' +import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -/** Identity-stable no-session bundle (uSES getSnapshot contract). */ -const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } - afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -40,117 +33,38 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command content: [], isError: false, callView: null, resultView: null, }) -function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { - return { - sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, - } -} - /** Test-owned AppFrame role: declares and renders the resident conversation area. */ type AppRootProps = PropsRenderSlots<'conversation' | 'details'> function AppRoot({ renderSlot }: AppRootProps) { return <>{renderSlot('conversation', {})} } +const LAYOUT_CHILDREN = { + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, +} as const + /** - * Real-stack bench: SlotsService plugin, renderer installed, sessions/layout - * fakes at the service seams only (external boundaries), the package apply on - * its own fiber, and the test AppFrame occupying 'root'. + * Real-stack bench: SlotTestRuntime with the session/layout doubles at the + * service seams only (external boundaries), the package apply on its own + * fiber, and the test AppFrame occupying 'root'. */ async function bench(nodes: ToolResultNode[]) { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - - const session = createSnapshotStore(snapshotWith(nodes)) - const list = createSnapshotStore({ - ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, - current: SID, - phase: 'ready', - }) - // Identity-stable provide bundle: the renderer caches hooks per source and - // inject results per bundle, both by object identity. Registered providers - // (the package's input contribution) materialize into it lazily, once. - const providers: ((binding: object) => { hooks?: object; props?: object })[] = [] - let info: { sessionId: SessionId; hooks: object; props: object } | undefined - const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } + const runtime = await SlotTestRuntime.create() const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} } - const bindingOf = (id: SessionId) => ({ - sessionId: id, - ctx: actxFake, + runtime.provide('layout', layout) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S' }, + snapshot: { nodes }, session: { - sessionId: id, - loadOlder: vi.fn(), - prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), - // Observable face for the input machine's queue read face. - getSnapshot: () => session.getSnapshot(), - subscribe: (fn: () => void) => session.subscribe(fn), + loadOlder: vi.fn(), + prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), }, }) - const provideInfo = (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - for (const provider of providers) { - const c = provider(bindingOf(SID)) - Object.assign(hooks, c.hooks ?? {}) - Object.assign(props, c.props ?? {}) - } - info = { sessionId: SID, hooks, props } - } - return info - } - ctx.provide('sessions', { - list, - binding: bindingOf, - scope: () => actxFake, - provideInfo, - currentProvideInfo: { - getSnapshot: () => provideInfo(SID), - subscribe: () => () => {}, - }, - provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, - scopeOf: () => SID, - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - }) - const workspaces = { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspaces) - ctx.provide('layout', layout) - ctx.provide('locale', { bind: () => (key: string) => key }) - - slots.install(createSlotRenderer()) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) - - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, slots, fiber, session, list, layout, workspaces } -} - -/** Render the whole tree through the ctx-level root seam (the shell's own entry). */ -function mountApp(slots: SlotsService) { - return render(<>{slots.renderSlot('root', {})}) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + const feature = await runtime.mount({ inject: [...inject], apply }) + return { runtime, slots: runtime.slots, feature, layout } } describe('keyed toolview hole through the real machinery', () => { @@ -159,7 +73,7 @@ describe('keyed toolview hole through the real machinery', () => { toolResult(3, 'c1', 'bash'), toolResult(4, 'c2', 'mystery', '{"n":1}'), ]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() // bash: the sample plugin's keyed registration took the row (root // session → global arm, decided inside the component off useSessions). expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() @@ -167,6 +81,7 @@ describe('keyed toolview hole through the real machinery', () => { expect(view.getByText('Build')).toBeTruthy() // mystery: no registration under that key → render-site fallback. expect(view.getByText('Tool call')).toBeTruthy() + await b.runtime.dispose() }) it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { @@ -176,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => { toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })), toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'), ]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') const mounted = view.container.querySelector('[data-variant="code"]') @@ -186,42 +101,46 @@ describe('keyed toolview hole through the real machinery', () => { fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) + await b.runtime.dispose() }) it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) }) + await b.runtime.dispose() }) it('bash summary clicks do not open details or host paths', async () => { const b = await bench([toolResult(3, 'c1', 'bash')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() view.getByText('Build').click() expect(b.layout.openDetails).not.toHaveBeenCalled() - expect(b.workspaces.openPath).not.toHaveBeenCalled() + expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) + await b.runtime.dispose() }) it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => { const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() expect(view.getByText('Tool call')).toBeTruthy() let dispose = (): void => {} - await act(async () => { - dispose = b.slots.register( - { name: 'conversation.chat.toolview', key: 'mystery' }, - () =>
) - }) + dispose = b.slots.register( + { name: 'conversation.chat.toolview', key: 'mystery' }, + () =>
) + await b.runtime.flush() // Per-key version tick: the row flipped without a remount of the view. expect(view.getByTestId('mystery-row')).toBeTruthy() expect(view.queryByText('Tool call')).toBeNull() - await act(async () => { dispose() }) + dispose() + await b.runtime.flush() expect(view.queryByTestId('mystery-row')).toBeNull() expect(view.getByText('Tool call')).toBeTruthy() + await b.runtime.dispose() }) it('a duplicate key registration fails loud at load', async () => { @@ -230,6 +149,7 @@ describe('keyed toolview hole through the real machinery', () => { { name: 'conversation.chat.toolview', key: 'bash' }, () => null, )).toThrow(/key "bash"/) + await b.runtime.dispose() }) it('the inject channel feeds (sessionId) => I into the row component', async () => { @@ -247,66 +167,33 @@ describe('keyed toolview hole through the real machinery', () => { }, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => ( )) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() const row = view.getByTestId('probe-row') expect(row.textContent).toBe(`for:${SID}`) row.click() expect(poked).toEqual([SID]) + await b.runtime.dispose() }) }) describe('registrant load-order seam', () => { it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - ctx.provide('sessions', { - list: createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', - }), - binding: () => undefined, - scope: () => undefined, - provideInfo: () => undefined, - currentProvideInfo: { - getSnapshot: () => ABSENT_INFO, - subscribe: () => () => {}, - }, - provide: () => () => {}, - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - }) - ctx.provide('workspaces', { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - }) - ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('locale', { bind: () => (key: string) => key }) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) // Third-party posture, mounted BEFORE ui-conversation: real fiber inject // semantics hold it — apply must not run while 'conversation' is absent. - // (Plain arrow, not vi.fn: mock functions carry a prototype and trip the - // fiber's isConstructor branch.) + // Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount() + // would fail loud on the missing service). (Plain arrow, not vi.fn: mock + // functions carry a prototype and trip the fiber's isConstructor branch.) let applyRuns = 0 - const registrantApply = (registrantCtx: Context): void => { + const registrantApply = (registrantCtx: typeof runtime.ctx): void => { applyRuns += 1 registrantCtx.slots.register( { name: 'conversation.chat.toolview', key: 'late' }, () => null) } - const late = ctx.plugin({ + const late = runtime.ctx.plugin({ name: 'late-registrant', inject: ['slots', 'conversation'], apply: registrantApply, @@ -317,11 +204,11 @@ describe('registrant load-order seam', () => { // Mounting the package resolves the seam: service present ⟹ the chat // entry (and its hole declaration) is already on the ledger, so the // suspended registrant lands without an undeclared-slot throw. - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + await runtime.mount({ inject: [...inject], apply }) await late.await() expect(applyRuns).toBe(1) - expect(slots.entries('conversation.chat.toolview').map(e => e.options.key)) + expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) + await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts deleted file mode 100644 index 533d5ce730..0000000000 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -// @vitest-environment jsdom -/** - * Exercises selection persistence through the real SlotsService store axis; - * component stubs cannot prove per-session identity or disposal. - */ -import { Context } from 'cordis' -import { beforeEach, describe, expect, it } from 'vitest' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' -import { createChatStore } from '../src/client/stores.ts' - -const sid = (s: string): SessionId => s as SessionId - -/** Identity-stable no-session bundle (uSES getSnapshot contract). */ -const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } - -interface Bench { - slots: SlotsService - chat: ReturnType -} - -function bench(): Bench { - const ctx = new Context() - ctx.provide('sessions', { - list: createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', - }), - provideInfo: () => undefined, - currentProvideInfo: { - getSnapshot: () => ABSENT_INFO, - subscribe: () => () => {}, - }, - provide: () => () => {}, - }) - ctx.provide('workspaces', { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - }) - // Service self-registers as ctx 'slots' (cordis Service constructor). - const slots = new SlotsService(ctx) - const chat = createChatStore() - // The apply.ts shape: one shared handle across both session-slot - // registrations. 'conversation'/'details' must first exist in the ledger — - // register a root occupant declaring them (the AppFrame role; the stand-in - // consumes renderSlot to satisfy the declare-means-render check). - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'conversation.session': { kind: 'single', scope: 'session' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, (_p: { renderSlot?: unknown }) => null) - // apply.ts mounts the shared chat handle only under session-scope slots - // (the session-maybe 'conversation' shell carries no store). - slots.register({ name: 'conversation.session', store: chat }, () => null) - slots.register({ name: 'details', store: chat }, () => null) - return { slots, chat } -} - -/** Resolve the store instance the renderer would hand a slot's component for a session. */ -function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) { - const host = renderHost(b) - const entry = host.entriesOf(slot)[0]! - return host.storeOf(entry, sessionId)! as ReturnType['create']> -} - -/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */ -function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost { - const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost }) - if (captured._host === undefined) { - b.slots.install({ - renderRoot: (host) => { - captured._host = host - return null - }, - }) - b.slots.renderSlot('root', {}) - } - return captured._host! -} - -beforeEach(() => { - localStorage.clear() -}) - -describe('selection survives on the store seat', () => { - it('one session, two slots: conversation writes, details reads the SAME instance', () => { - const b = bench() - - const conv = storeFor(b, 'conversation.session', sid('s1')) - const details = storeFor(b, 'details', sid('s1')) - conv.actions.select({ turnSeq: 3, callId: 'c1' }) - expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - // Identity, not just value: the shared handle resolves one instance per scope key. - expect(details).toBe(conv) - }) - - it('sessions are isolated: s2 selection never bleeds into s1', () => { - const b = bench() - - const one = storeFor(b, 'conversation.session', sid('s1')) - const two = storeFor(b, 'conversation.session', sid('s2')) - expect(two).not.toBe(one) - one.actions.select({ turnSeq: 1, callId: 'a' }) - two.actions.select({ turnSeq: 9, callId: 'z' }) - expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) - expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) - }) - - it('a list-projection update keeps instance identity and the selection value', () => { - const b = bench() - const id = sid('s1') - const projection = createSnapshotStore({ displayTitle: 's1' }) - - const store = storeFor(b, 'conversation.session', id) - store.actions.select({ turnSeq: 3, callId: 'c1' }) - store.actions.setDraft('half-typed') - - projection.set({ displayTitle: 'proj-a' }) - expect(projection.getSnapshot().displayTitle).toBe('proj-a') - - const after = storeFor(b, 'conversation.session', id) - expect(after).toBe(store) - expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - expect(after.store.getSnapshot().draft).toBe('half-typed') - }) - - it('session death buries the instance and its persisted draft', () => { - const b = bench() - - const doomed = storeFor(b, 'conversation.session', sid('s1')) - doomed.actions.setDraft('to be buried') - doomed.actions.select({ turnSeq: 1 }) - expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() - - // SessionsService calls this public slot lifecycle seam when the scope dies. - b.slots.pruneStoreScope(sid('s1')) - - // Persisted residue is gone with the session... - expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() - // ...and a re-created same-id session starts from a FRESH instance. - const reborn = storeFor(b, 'conversation.session', sid('s1')) - expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) - }) -}) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx new file mode 100644 index 0000000000..70162105a9 --- /dev/null +++ b/packages/client/ui-conversation/tests/selection-survival.spec.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/** + * Exercises selection persistence through the real SlotsService store axis; + * component stubs cannot prove per-session identity or disposal. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createChatStore } from '../src/client/stores.ts' + +const sid = (s: string): SessionId => s as SessionId + +type ChatInstance = ReturnType['create']> + +async function bench() { + const runtime = await SlotTestRuntime.create() + const chat = createChatStore() + // The apply.ts shape: one shared handle across both strict-session slot + // registrations ('conversation.session'/'details'); the session-maybe + // 'conversation' shell carries no store by design. The slots must first + // exist in the ledger — the test root declares them (the AppFrame role). + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'conversation.session': { kind: 'single', scope: 'session' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_p: { renderSlot?: unknown }) => null) + runtime.slots.register({ name: 'conversation.session', store: chat }, () => null) + runtime.slots.register({ name: 'details', store: chat }, () => null) + runtime.renderRoot() // materializes the host face storeOf resolves through + return { runtime, chat } +} + +/** Resolve the store instance the renderer would hand a slot's component for a session. */ +function storeFor(b: Awaited>, slot: 'conversation.session' | 'details', sessionId: SessionId) { + return b.runtime.storeOf(slot, sessionId) as ChatInstance +} + +beforeEach(() => { + localStorage.clear() +}) + +describe('selection survives on the store seat', () => { + it('one session, two slots: conversation writes, details reads the SAME instance', async () => { + const b = await bench() + + const conv = storeFor(b, 'conversation.session', sid('s1')) + const details = storeFor(b, 'details', sid('s1')) + conv.actions.select({ turnSeq: 3, callId: 'c1' }) + expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + // Identity, not just value: the shared handle resolves one instance per scope key. + expect(details).toBe(conv) + await b.runtime.dispose() + }) + + it('sessions are isolated: s2 selection never bleeds into s1', async () => { + const b = await bench() + + const one = storeFor(b, 'conversation.session', sid('s1')) + const two = storeFor(b, 'conversation.session', sid('s2')) + expect(two).not.toBe(one) + one.actions.select({ turnSeq: 1, callId: 'a' }) + two.actions.select({ turnSeq: 9, callId: 'z' }) + expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) + expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) + await b.runtime.dispose() + }) + + it('a list-projection update keeps instance identity and the selection value', async () => { + const b = await bench() + const id = sid('s1') + + const store = storeFor(b, 'conversation.session', id) + store.actions.select({ turnSeq: 3, callId: 'c1' }) + store.actions.setDraft('half-typed') + + // A projection churn elsewhere (list rows re-projected) must not touch + // store identity: drive the runtime's own list observable. + await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } }) + expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a') + + const after = storeFor(b, 'conversation.session', id) + expect(after).toBe(store) + expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + expect(after.store.getSnapshot().draft).toBe('half-typed') + await b.runtime.dispose() + }) + + it('session death buries the instance and its persisted draft', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: 's1' }) + + const doomed = storeFor(b, 'conversation.session', sid('s1')) + doomed.actions.setDraft('to be buried') + doomed.actions.select({ turnSeq: 1 }) + expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() + + // TestSessions.remove drives the same public slot lifecycle seam the + // production SessionsService calls when the scope dies (pruneStoreScope). + await b.runtime.sessions.remove('s1') + + // Persisted residue is gone with the session... + expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() + // ...and a re-created same-id session starts from a FRESH instance. + const reborn = storeFor(b, 'conversation.session', sid('s1')) + expect(reborn).not.toBe(doomed) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index b364dde92a..4897fe5769 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -1,39 +1,32 @@ // @vitest-environment jsdom +// ConversationService scope addressing over the runtime's real scope tag: +// TestSessions mints tagged scopes through the production createScope, so the +// service's scopeOf/binding path runs against production resolution (no local +// tag probe). import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { InputHub } from '../src/client/input/hub.ts' -const sid = (id: string) => id as SessionId -const SCOPE_TAG: symbol = (() => { - const reads: (string | symbol)[] = [] - const proxy = new Proxy(new Context(), { - get(target, property, receiver): unknown { - reads.push(property) - return Reflect.get(target, property, receiver) - }, - }) - void scopeOf(proxy) - return reads.find((value): value is symbol => typeof value === 'symbol')! -})() - -async function bench(withSessions = true) { - const ctx = new Context() +async function bench() { + const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) - const sessions = { - binding: (sessionId: SessionId) => ({ - sessionId, session: { prompt, cancel, loadOlder }, - }), - scopeOf, - } as unknown as SessionsService - if (withSessions) ctx.provide('sessions', sessions) - await ctx.plugin(ConversationService).await() - const root = ctx.get('conversation') as ConversationService - const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService - return { root, scoped, prompt, cancel, loadOlder } + await runtime.sessions.add({ + id: 's1', + session: { prompt, cancel, loadOlder }, + }) + // config.input is required (the apply shares its hub with the inject + // factories); the bench passes its own instance explicitly. + const fiber = runtime.ctx.plugin(ConversationService, { + input: new InputHub(runtime.ctx), + }) + await fiber.await() + const root = runtime.ctx.get('conversation') as ConversationService + const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService + return { runtime, root, scoped, prompt, cancel, loadOlder } } describe('ConversationService', () => { @@ -45,6 +38,7 @@ describe('ConversationService', () => { expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() + await b.runtime.dispose() }) it('folds Session business failures into callback rejections', async () => { @@ -53,12 +47,21 @@ describe('ConversationService', () => { await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy') b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') + await b.runtime.dispose() }) - it('fails loudly from the root scope or without SessionsService', async () => { + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) - const missing = await bench(false) - await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) + await b.runtime.sessions.remove('s1') + await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/) + await b.runtime.dispose() + // No SessionsService at all: a bare context (the runtime always provides one). + const bare = new Context() + await bare.plugin(ConversationService, { + input: new InputHub(bare), + }).await() + const orphan = bare.get('conversation') as ConversationService + await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) }) }) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 0bd34e322e..91b14f32d7 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.spec.tsx new file mode 100644 index 0000000000..10acab85d8 --- /dev/null +++ b/packages/client/web/tests/app-shell.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/** + * App-shell assembly plugin on the real machinery: bare Context + production + * SlotsService + the test-runtime session/workspace doubles. Deliberately NOT + * mounted through SlotTestRuntime — its create() installs the capturing + * renderer and install() is boot-once; app-shell IS the production installer, + * so this bench hands it the uninstalled service exactly as boot does. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { Context } from 'cordis' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime' +import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' +import * as AppShell from '@deepseek-ai/dsh-client-web/src/app-shell.ts' + +afterEach(cleanup) + +const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const slots = ctx.get('slots') as SlotsService + ctx.provide('sessions', new TestSessions(stabilize, ctx)) + ctx.provide('workspaces', new TestWorkspaces(stabilize)) + ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + const fiber = ctx.plugin({ inject: [...AppShell.inject], apply: AppShell.apply }) + await fiber.await() + return { ctx, slots, fiber } +} + +describe('app-shell assembly plugin', () => { + it('installs the renderer and provides the assembled appShell face', async () => { + const { ctx, slots } = await bench() + slots.register({ name: 'root' }, () =>
) + const shell = ctx.get('appShell') + expect(shell).toBeDefined() + const view = render(<>{shell!.renderApp()}) + expect(view.getByTestId('root-probe')).toBeTruthy() + }) + + it('assembles once: repeated renderApp calls reuse the built closure', async () => { + const { ctx, slots } = await bench() + slots.register({ name: 'root' }, () =>
) + const shell = ctx.get('appShell')! + const first = render(<>{shell.renderApp()}) + expect(first.getByTestId('root-probe')).toBeTruthy() + first.unmount() + // Second call rides the cached closure (renderApp ??=) and still renders. + const second = render(<>{shell.renderApp()}) + expect(second.getByTestId('root-probe')).toBeTruthy() + }) + + it('fiber dispose retracts the service and uninstalls the renderer', async () => { + const { ctx, slots, fiber } = await bench() + await stabilize(() => fiber.dispose()) + expect(ctx.get('appShell')).toBeUndefined() + expect(() => slots.renderSlot('root', {})).toThrow('not installed') + }) +}) diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.spec.tsx new file mode 100644 index 0000000000..b8f5cb2fde --- /dev/null +++ b/packages/client/web/tests/app.spec.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/** + * buildRenderApp on SlotTestRuntime: the fail-loud sessions precondition, the + * one ctx-level renderSlot('root') call, and the document-title projection + * arms over the real slot stack. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { Context } from 'cordis' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx' + +let runtime: SlotTestRuntime | undefined + +afterEach(async () => { + cleanup() + await runtime?.dispose() + runtime = undefined + document.title = '' +}) + +async function bench() { + runtime = await SlotTestRuntime.create() + await runtime.root.declare({}, () =>
) + return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) } +} + +describe('buildRenderApp', () => { + it('fails loud when the sessions service is unavailable', () => { + expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable') + }) + + it('renders the root slot tree through the one ctx-level renderSlot call', async () => { + const b = await bench() + const view = render(<>{b.renderApp()}) + expect(view.getByTestId('frame')).toBeTruthy() + }) + + it('projects the current session durable title and falls back to the product title', async () => { + document.title = 'Product' + const b = await bench() + render(<>{b.renderApp()}) + // No current session: the product title stands. + expect(document.title).toBe('Product') + await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) + expect(document.title).toBe('First — Product') + await b.runtime.sessions.setCurrent(undefined) + expect(document.title).toBe('Product') + // A session without a durable title keeps the product title. + await b.runtime.sessions.add({ id: 's2' }) + expect(document.title).toBe('Product') + }) + + it('a current id without a list row falls back (selection/list arbitration transient)', async () => { + document.title = 'Product' + const b = await bench() + await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) + render(<>{b.renderApp()}) + expect(document.title).toBe('First — Product') + b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId }) + await b.runtime.flush() + expect(document.title).toBe('Product') + }) +}) From 3e6a7fea43ec18b325620b01172228e12e0b0f18 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:04:55 +0800 Subject: [PATCH 56/61] test(ui-sidebar): pin the shell chrome with local DOM snapshots First consumer of the runtime's single-slot mounting: the real apply mounts on its own fiber, renderSlot('sidebar', ...) captures exactly the sidebar slot's output, and update() re-renders the collapsed rail in place. The .snap files carry semantic class names and svg fingerprints only. --- packages/client/ui-sidebar/package.json | 1 + .../sidebar-snapshot.spec.tsx.snap | 131 ++++++++++++++++++ .../tests/sidebar-snapshot.spec.tsx | 51 +++++++ 3 files changed, 183 insertions(+) create mode 100644 packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap create mode 100644 packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 5f7b4d0f23..7d85b111cf 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap new file mode 100644 index 0000000000..6678d3088e --- /dev/null +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap @@ -0,0 +1,131 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = ` +
+