diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 88d86d6fca..a717ec463e 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: bd6f6e58c571102afc789ef57085db1e302158cc -2026-07-23-client-plugin-loading-model.zh.md: 256b57102bbec6f793d48d0bdaf60445b194ecdf +2026-07-23-client-plugin-loading-model.md: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b +2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index bd6f6e58c5..0fe4e86410 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -72,7 +72,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. -How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle and optional-map stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it reads the current source map as part of that new artifact snapshot, while a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. On the browser side, the driver reloads one plugin per frame, serialized: diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 256b57102b..386b0edb72 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。 -重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 +重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 浏览器侧,驱动插件每帧重载一个插件,串行执行: diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index bf06cd8dee..a17ddc19c3 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.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/architecture/2026-08-20-webworker-node-face.md -2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81 -2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371 +2026-08-20-webworker-node-face.md: 05c3bfe7fa07b34189229f6454be597d4956226a +2026-08-20-webworker-node-face.zh.md: 0a0f4badff192f02e962cfb38b64ef9d89fcd420 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md index 41a30dedc7..05c3bfe7fa 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work ## Decision -**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. +**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. The shim includes `process.title`: packages such as `@xterm/headless` use that property's presence to select their Node path, while omitting it makes a dedicated Worker look like a browser Window and reaches DOM-only globals. **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. @@ -31,4 +31,5 @@ The worker runs the web profile's Cordis configuration byte for byte — no work - `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world. - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`. - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts. +- `tests/node/process-shim.spec.ts` pins the Node detection field independently from the test runner's ambient Node process. - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md index b574813358..0a0f4badff 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 ## 决定 -**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。 +**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。Shim 包含 `process.title`:`@xterm/headless` 等包通过该属性是否存在来选择 Node 路径;缺少它会让 dedicated Worker 被误判为浏览器 Window,进而访问仅适用于 DOM 的全局对象。 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 @@ -31,4 +31,5 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 - `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。 +- `tests/node/process-shim.spec.ts` 独立于测试运行器自带的 Node process,钉住 Node 环境识别字段。 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml index b1b1da3344..8803223d04 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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/architecture/2026-08-20-webworker-pack-lowering-and-preview.md -2026-08-20-webworker-pack-lowering-and-preview.md: 1ec8fb050445b0a90d8fbf0d97c9ef10cb28b287 -2026-08-20-webworker-pack-lowering-and-preview.zh.md: 86da66560b509b38ba3ffa49d58035f3e5a173f1 +2026-08-20-webworker-pack-lowering-and-preview.md: 24dd5ba6e3eb633253321b219781b537f1faf429 +2026-08-20-webworker-pack-lowering-and-preview.zh.md: 5ee59e9a508767be928e2de804514b6357fe4ffd diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md index 1ec8fb0504..24dd5ba6e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md @@ -10,7 +10,7 @@ The browser worker can neither compile modules at load nor be served by the prod ## Decision -**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there. +**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; the same parse feeds reachability with statically named imports, re-exports, and dynamic imports, calls through `require`, and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import. Stored results, CommonJS-obtained `createRequire`, computed request names, and other bases stay runtime-only; targets reachable only through those forms require image entry seeds. Inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. Source-directory exclusion applies only to workspace and vendored packages whose runtime plane is built `lib/`; installed third-party packages retain JavaScript under `src/` and `dist/` because their published entrypoints may resolve there. **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. Plugin combo scripts and maps travel through the tunnel; the page-side loader embeds each tunnel-only map as a Base64 data URL before executing its script Blob, preserving indexed-map component names in DevTools without another object-URL lifetime. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared. @@ -37,7 +37,7 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental ## Consequences - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed). -- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts. +- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, the transform semantic suite pins `createRequire` request discovery, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts. - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not. - The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base. - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md index 86da66560b..5ee59e9a50 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md @@ -10,7 +10,7 @@ ## 决定 -**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。 +**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;同一次解析会把具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用送入可达性遍历。保存下来的结果、经 CommonJS 获取的 `createRequire`、计算得到的请求名称与其他基准只在运行时解析;只能通过这些形式触达的目标需要镜像入口种子。worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。源码目录排除只用于运行期使用已构建 `lib/` 的 workspace 与 vendored 包;已安装第三方包会保留 `src/` 和 `dist/` 下的 JavaScript,因为其发布入口可能解析到这些位置。 **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。插件 combo 脚本与 map 都通过 tunnel;页面侧 loader 会在执行脚本 Blob 前,把每个仅 tunnel 可达的 map 内嵌为 Base64 data URL,从而不依赖另一条 object URL 的生命周期,并在 DevTools 中保留 indexed map 的组件名称。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。 @@ -37,7 +37,7 @@ ## 后果 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。 -- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。 +- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,transform 语义套件钉住 `createRequire` 请求发现,`apps/web/tests/preview-boot.e2e.ts` 则在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。 - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。 - served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml new file mode 100644 index 0000000000..abc20bb19c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.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-08-25-persistence-latency-and-page-size.md +2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00 +2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md new file mode 100644 index 0000000000..27eb58cc55 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md @@ -0,0 +1,70 @@ +# Agent Note: Persistence compression latency and SQLite page size + +Status: implemented + +English | [中文](2026-08-25-persistence-latency-and-page-size.zh.md) + +## Problem + +The physical persistence optimizations need to reduce retained storage without moving disproportionate work into full writes, reads, or session forks. The original 105-session corpus showed that JSONL level-19 compression made full writes and forks more than twice as slow. The earlier SQLite page-size experiment predated shared-dictionary row compression and showed negligible savings, so it did not establish the best page size for the current row distribution. + +The decision needs evidence from more varied sessions, including long event streams and payloads outside the original corpus. The expanded corpus contains 501 real sessions, 16,153,332 logical events, and 2,002,145,570 bytes of serialized event data. + +## Decision + +### Storage encoding stays physical and independently decodable + +JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event. + +SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump. + +### JSONL uses the standard Zstandard level + +The JSONL writer keeps one checksummed Zstandard frame per durable append batch but uses the compressor's standard level. Lossless `sourceEventSeqs` range encoding remains active. Frames stay independently decodable for suffix reads and torn-tail recovery; only the expensive level-19 search is removed. + +### New SQLite databases use 64 KiB pages + +The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation. + +The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas. + +### Expanded benchmark + +Each candidate was rebuilt five times from the same 501-session corpus with 512-event append batches. Their order rotates between rounds so every candidate occupies each run position once. Each build runs three complete and suffix-read sweeps. For each displayed metric, the highest and lowest build are discarded and the remaining three values are averaged. Complete and suffix read times cover one sweep over all sessions, and fork time covers all 501 sessions. + +| Backend | Stored size | Full write | Full read | Suffix read | Fork | +| --- | ---: | ---: | ---: | ---: | ---: | +| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s | +| JSONL with provenance ranges | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) | +| JSONL with provenance ranges and level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) | +| SQLite `master` (schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s | +| SQLite with all physical optimizations and 64 KiB pages | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) | + +Relative to standard-level frames with provenance ranges, level 19 saves another 12.1% of the JSONL bytes but increases full-write time by 67.0% and fork time by 129.8%. Its complete and suffix reads change by -0.4% and -0.5%. The extra search therefore benefits retained size without improving the latency-sensitive operations enough to offset its repeated encoding cost. + +An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages use 256.97 MB and 64 KiB pages use 233.18 MB (-9.26%). The `events` table's unused page bytes fall from 30.25 MB to 6.95 MB, while the index changes from 5.92 MB to 6.03 MB. In the paired run, full write, full read, and suffix read change by -0.5%, -0.4%, and -3.8%; fork changes by -14.8%. The space gain therefore comes from better large-row page utilization rather than a smaller index or omitted data, without a measured latency regression. + +## Alternatives considered + +**Keep JSONL level 19.** Rejected. On the expanded corpus it saves another 12.1% relative to default-level frames but increases full-write time by 67.0% and fork time by 129.8%, while complete and suffix reads differ by less than 1%. Default-level frames plus provenance ranges retain a 14.1% size reduction relative to master without a material latency regression. + +**Compress one whole JSONL log as a single frame.** Rejected. It improves cross-batch compression but makes suffix reads decompress from the start and removes batch-local torn-tail recovery. + +**Keep 4 KiB SQLite pages.** Rejected for pristine databases. The current compressed-row distribution retains 9.26% more bytes because large compressed records leave more unusable space across 4 KiB B-tree pages. Existing databases keep their page size to avoid a historical rewrite. + +**Remove ROWID from `events`.** Rejected. The composite primary key becomes the table B-tree key and repeats through internal pages; the 105-session comparison produced a larger database than ordinary ROWID tables. + +**Deduplicate event content.** Rejected. Message restatements and tool arguments can be reconstructed only under assumptions that compaction, retries, and pruning may invalidate. Physical compression preserves every event without adding reconstruction semantics. + +**Use per-session SQLite files or DuckDB.** Rejected for the hot store. Per-session files lose cross-session queries, while DuckDB's OLAP write model fits cold batch analysis rather than durable append batches and low-latency suffix reads. + +## Consequences + +JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus. + +New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it. + +## Related + +- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.md) — owns the packed row model; its earlier page-size conclusion applies to the pre-dictionary layout. +- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.md) — owns the checksummed frame-per-batch container and the standard compressor-level policy restored here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md new file mode 100644 index 0000000000..24ab1835cc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md @@ -0,0 +1,70 @@ +# Agent Note: 持久化压缩延迟与 SQLite page size + +Status: implemented + +[English](2026-08-25-persistence-latency-and-page-size.md) | 中文 + +## 问题 + +物理持久化优化需要减少保留存储,同时不能把不成比例的工作转移到完整写入、读取或会话 fork。原有的 105 会话语料显示,JSONL level-19 压缩会让完整写入与 fork 耗时增加一倍以上。此前的 SQLite page-size 实验早于共享字典行压缩,所得空间收益可以忽略,因此无法确定当前行分布的最佳 page size。 + +该决策需要来自更多样会话的证据,包括长事件流与原语料之外的 payload。扩展后的语料包含 501 个真实会话、16,153,332 个逻辑事件与 2,002,145,570 字节序列化事件数据。 + +## 决策 + +### 存储编码保持为物理层行为并可独立解码 + +JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。 + +SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。 + +### JSONL 使用 Zstandard 标准级别 + +JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Zstandard frame,但使用压缩器的标准级别。无损 `sourceEventSeqs` 区间编码继续生效。各 frame 仍可独立解码,以支持后缀读取与撕裂尾部恢复;只移除昂贵的 level-19 搜索。 + +### 新建 SQLite 数据库使用 64 KiB page + +SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。 + +Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。 + +### 扩展基准 + +每个候选方案都从同一份 501 会话语料独立重建五次,每个 append 批次包含 512 个事件。各轮轮换执行顺序,使每个候选方案在每个运行位置各出现一次。每次重建执行三轮完整读取与后缀读取。下表中的每项指标都去掉最高与最低的一次重建,再平均其余三次。完整读取与后缀读取耗时覆盖对全部会话的一轮扫描,fork 耗时覆盖全部 501 个会话。 + +| 后端 | 存储大小 | 完整写入 | 完整读取 | 后缀读取 | Fork | +| --- | ---: | ---: | ---: | ---: | ---: | +| JSONL `master` | 172.43 MB | 200.902 s | 8.033 s | 24.479 s | 72.670 s | +| JSONL + 来源区间 | 148.15 MB (-14.1%) | 197.281 s (-1.8%) | 7.799 s (-2.9%) | 24.582 s (+0.4%) | 72.308 s (-0.5%) | +| JSONL + 来源区间 + level 19 | 130.22 MB (-24.5%) | 329.442 s (+64.0%) | 7.764 s (-3.3%) | 24.454 s (-0.1%) | 166.177 s (+128.7%) | +| SQLite `master`(schema 17) | 438.31 MB | 69.632 s | 8.211 s | 0.546 s | 64.290 s | +| SQLite + 全部物理优化 + 64 KiB page | 233.18 MB (-46.8%) | 87.656 s (+25.9%) | 9.155 s (+11.5%) | 0.575 s (+5.3%) | 79.417 s (+23.5%) | + +相对使用来源区间的标准级别 frame,level 19 可再减少 12.1% 的 JSONL 字节,但会让完整写入增加 67.0%、fork 增加 129.8%;完整读取与后缀读取分别变化 -0.4% 与 -0.5%。因此,更深入的搜索只改善保留体积,无法通过延迟敏感操作的收益抵消反复付出的编码成本。 + +其余条件相同的 SQLite 重建可单独观察 page-size 影响:4 KiB page 使用 256.97 MB,64 KiB page 使用 233.18 MB(-9.26%)。`events` 表的 page 内未使用字节从 30.25 MB 降至 6.95 MB,索引则从 5.92 MB 变为 6.03 MB。在该成对运行中,完整写入、完整读取与后缀读取分别变化 -0.5%、-0.4% 与 -3.8%,fork 变化 -14.8%。因此,空间收益来自更高的大记录 page 利用率,而不是索引缩小或数据省略,并且没有测得延迟退化。 + +## 考虑过的替代方案 + +**保留 JSONL level 19。** 不予采用。在扩展语料上,它相对默认级别 frame 可再减少 12.1%,却让完整写入增加 67.0%、fork 增加 129.8%,而完整读取与后缀读取的差异都不足 1%。默认级别 frame 配合来源区间后,相对 master 仍能缩小 14.1%,且没有实质性延迟退化。 + +**把整份 JSONL 日志压成单个 frame。** 不予采用。该方案可改善跨批次压缩,但后缀读取必须从头解压,也会失去按批次恢复撕裂尾部的能力。 + +**新建 SQLite 数据库继续使用 4 KiB page。** 不予采用。当前压缩行分布会在 4 KiB B-tree page 之间留下更多不可用空间,使保留字节增加 9.26%。已有数据库保留其 page size,避免改写历史数据。 + +**从 `events` 移除 ROWID。** 不予采用。复合主键会成为表 B-tree 键并在内部 page 中重复;105 会话对比所得数据库大于使用普通 ROWID 的表。 + +**对事件内容去重。** 不予采用。消息复述与工具参数只能在依赖重建假设时删除,而 compaction、重试和修剪可能让这些假设失效。物理压缩保留每个事件,不增加重建语义。 + +**使用逐会话 SQLite 文件或 DuckDB。** 不用于热存储。逐会话文件会失去跨会话查询,DuckDB 的 OLAP 写入模型则更适合冷批量分析,而不是持久 append 批次与低延迟后缀读取。 + +## 后果 + +JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。 + +新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。 + +## 相关资料 + +- [sqlite-physical-chunk-row-compression](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) — 定义打包行模型;其此前的 page-size 结论适用于共享字典之前的布局。 +- [zstandard-jsonl-session-logs](2026-07-19-zstandard-jsonl-session-logs.zh.md) — 定义带 checksum 的按批次 frame 容器,以及本笔记恢复的标准压缩级别策略。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 00c69e74d8..367b83634d 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -23,7 +23,9 @@ import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-selection', import.meta.url)) const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') @@ -287,7 +289,7 @@ describe('web e2e: agent-preset selection', () => { // `minimal` mounts neither the compaction group nor plan mode nor local // skill discovery, so the catalog the composer warmed under the // deployment default must not survive the switch. - await composer.fill('/') + await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) const onMinimal = await menuOptions(page) @@ -297,7 +299,7 @@ describe('web e2e: agent-preset selection', () => { // remains outside every preset. expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false) expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) - await composer.fill('') + await writeComposerDraft(page, composer, '') // Switching back up reaches the host at all — the chip compares the pick // against its list row, so a row that never reprojected the first switch @@ -307,14 +309,14 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') - await composer.fill('/') + await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) const onStandard = await menuOptions(page) expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) - await composer.fill('') + await writeComposerDraft(page, composer, '') }, 90_000) it('labels a resumed session with the preset it was created under', async () => { diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index f900990fab..199b3f6ef9 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -106,7 +106,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => await writeComposerDraft(page, input, PROMPTS[index] as string) await input.press('Enter') sessionId = await settled - await expect.poll(() => input.getAttribute('contenteditable'), { timeout: 10_000 }).toBe('true') + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) } await writeComposerDraft(page, input, '/permission read-only') diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 147bf7ea50..6f8371f99f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -267,7 +267,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('keeps known descendants reachable across a stale empty catalog response', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) - const pattern = '**/api/subagent.list' + const pattern = '**/api/subagents/list' let firstClaimed = false let emptyDelivered = false let trailingRequested = false @@ -373,7 +373,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('keeps a restored child neutral until its parent availability arrives', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore')) - const pattern = '**/api/subagent.list' + const pattern = '**/api/subagents/list' let requested = false let releaseCatalog = (): void => {} const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) @@ -546,7 +546,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() const promptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagent.prompt') + new URL(response.url()).pathname === '/api/subagents/prompt') await input.fill(POST_FORK_FOLLOWUP) await input.press('Enter') const promptReceipt = await (await promptResponse).json() as { diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 1975e0b614..781c38f024 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: the composer's independent Stop interrupts a running // continuable child. The child holds its model turn open through a replay // hang entry; the browser proves Send and Stop coexist, the parent-offline -// disabled-Send-with-Stop composer, the subagent.interrupt +// disabled-Send-with-Stop composer, the subagents/interruptByParent // (never session.cancel) transport, the parked follow-up, and the FIFO resume // on a waking send. // @@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co // parentAvailable: false while the child Activation stays live (the // interrupt RPC itself needs no live parent — covered host-side by // subagent-interrupt.e2e.ts). - const pattern = '**/api/subagent.list' + const pattern = '**/api/subagents/list' await page.route(pattern, async (route) => { const response = await route.fetch() const body = await response.json() as { @@ -228,7 +228,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co ) const aborted = waitForAbortedTurn(scaffold, childId) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagent.interrupt') + new URL(response.url()).pathname === '/api/subagents/interruptByParent') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } @@ -252,7 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co } }, 60_000) - it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => { + it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) // Reselect the child with the truthful catalog: parent available again. await page.getByRole('navigation', { name: 'Session hierarchy' }) @@ -265,7 +265,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co // Queue a follow-up through Send while independent Stop remains available. const promptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagent.prompt') + new URL(response.url()).pathname === '/api/subagents/prompt') await input.fill(FOLLOWUP) await page.getByRole('button', { name: 'Send message' }).click() expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result) @@ -275,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagent.interrupt') + new URL(response.url()).pathname === '/api/subagents/interruptByParent') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 6edb81e404..082bc0802b 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -1,4 +1,4 @@ -// Web e2e scenario (browserless): the subagent.interrupt RPC against the real +// Web e2e scenario (browserless): the subagents interrupt Remote against the real // composition. A live continuable child holds its model turn open through a // replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and // proves from the real session state that the turn aborted, the follow-up @@ -22,25 +22,12 @@ const WAKING = 'And add one concrete example.' type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } -/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */ -async function rpc(scaffold: WebScaffold, method: string, payload: unknown): Promise> { - const response = await scaffold.hostFetch(`/api/${method}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', - rpcId: `interrupt-e2e-${method}-${randomUUID()}`, - method, - payload, - }), - }) - if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) - return (await response.json() as { result: RpcResult }).result -} - -/** POST one generated Session Remote unary through the API Gateway carrier. */ -async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { - const endpoint = `session/${method}` +/** POST one generated Remote unary through the API Gateway carrier. */ +async function remote( + scaffold: WebScaffold, + endpoint: string, + args: Readonly>, +): Promise> { const response = await scaffold.hostFetch(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -48,13 +35,18 @@ async function sessionRemote(scaffold: WebScaffold, method: string, request: type: 'client-request', rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`, method: endpoint, - payload: { args: { request } }, + payload: { args }, }), }) if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`) return (await response.json() as { result: RpcResult }).result } +/** POST one generated Session Remote unary through the API Gateway carrier. */ +function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { + return remote(scaffold, `session/${method}`, { request }) +} + /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs @@ -78,7 +70,7 @@ function textCompletion(text: string): object { } } -describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => { +describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => { let scaffold: WebScaffold let sidecarRoot: string let readyFile: string @@ -138,18 +130,21 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => { // Queue the follow-up while the turn is still open, then interrupt. - const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', { - parentSessionId: parentId, - childSessionId: childId, - mode: 'continuable', - content: [{ type: 'text', text: FOLLOWUP }], + const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { + request: { + requestId: randomUUID(), + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: FOLLOWUP }], + }, }) expect(queued).toMatchObject({ ok: true }) const settled = scaffold.whenTurnSettled() - const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', { - parentSessionId: parentId, + const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', { childSessionId: childId, + parentSessionId: parentId, mode: 'continuable', }) expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } }) @@ -169,11 +164,14 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co // Only an explicit waking send resumes the parked queue, FIFO, then the // child runs both turns to completion and settles. - const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', { - parentSessionId: parentId, - childSessionId: childId, - mode: 'continuable', - content: [{ type: 'text', text: WAKING }], + const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { + request: { + requestId: randomUUID(), + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: WAKING }], + }, }) expect(waking).toMatchObject({ ok: true }) await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 90659cf2b5..07ed9330b2 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -6,12 +6,24 @@ // selecting the ledger record renders the shared ui-attachment gallery from // the durable session-log reference, and the browser URL is the SAME object // URL Chat resolved — one sessions.attachment read per session attachment. -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it, vi } from 'vitest' import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' installAssembledBootEnv() +/** + * How long the mounted tree waits out the virtual ledger's scroll-idle timer. + * jsdom fires no `scrollend`, so `@tanstack/react-virtual` falls back to a + * debounce it re-arms on every scroll event (`isScrollingResetDelay`, 150ms by + * default) and its unsubscribe removes only the listeners; a scenario that + * ends inside that window leaves the timer to re-render the table after vitest + * has torn this file's jsdom down, where React reads a `window` that is gone. + * Armed later and with a longer delay than the debounce, this wait always + * expires after it. + */ +const SCROLL_IDLE_DRAIN_MS = 400 + /** Open the fixture history session and wait for the Chat gallery to load. */ async function openFixtureSession(): Promise { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) @@ -41,16 +53,24 @@ async function scrollRowIntoWindow(needle: string): Promise { }, { timeout: 10_000 }) const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing') - for (let top = 0; top <= 40_000; top += 1_000) { + const findRow = (): HTMLElement | undefined => + [...document.querySelectorAll('tr[data-trajectory-row-key]')] + .find(row => row.textContent?.includes(needle)) + let mounted = false + for (let top = 0; !mounted && top <= 40_000; top += 1_000) { pane.scrollTop = top fireEvent.scroll(pane) // Let the virtualizer publish the new window before probing. await new Promise(resolve => setTimeout(resolve, 25)) - const hit = [...document.querySelectorAll('tr[data-trajectory-row-key]')] - .find(row => row.textContent?.includes(needle)) - if (hit !== undefined) return hit + mounted = findRow() !== undefined } - throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`) + // Nothing scrolls the ledger after this, so draining the scroll-idle + // debounce here leaves no timer armed for the rest of the scenario. The + // drained reset re-renders the window, so the row is read afterwards. + await act(async () => { await new Promise(resolve => setTimeout(resolve, SCROLL_IDLE_DRAIN_MS)) }) + const hit = findRow() + if (hit === undefined) throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`) + return hit } it('renders durable record images in the Trajectory details panel from the shared cache', async () => { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a06cbd8903..a8fb510281 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 74834994902bd5fe1e75653ab5645aef5bf587be -config-catalog.zh.md: 77c83f46128a54b1a8f917e82ce3c6750060fcec +config-catalog.md: f91a64d73af53cc9c57d9d2e795f8f54bcf9eacf +config-catalog.zh.md: 8b1ab084668ef8e1568801855f9aebc19e76a5e3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7483499490..f91a64d73a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -774,7 +774,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1307,7 +1307,7 @@ export interface ReplayModelConfig { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts) +Source: [`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 77c83f4612..8b1ab08466 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -776,7 +776,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1309,7 +1309,7 @@ export interface ReplayModelConfig { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts) +来源:[`packages/test-support/llm-replay/src/index.ts:918`](../packages/test-support/llm-replay/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0f42ef8ea1..ed46752463 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: ff4a530f6071824da89c88a30a9744695f4d4eef -event-producer-consumer.zh.md: 074597f038b215731e8c005afcd2fed1bc8c4bd4 +event-producer-consumer.md: bcf7ec4f3418d6b598b7edecfb2e4386f1a42e97 +event-producer-consumer.zh.md: beba6bee877989ca2fc3d33d26a989be30069c33 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ff4a530f60..bcf7ec4f34 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -52,10 +52,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 074597f038..beba6bee87 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -54,10 +54,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e561402860..5e4627d0d4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: f2c8a499fc54ac90bd9b9a8a7f155ee56ad94dc7 -module-graph.zh.md: ff0d0c72020ed8cd23d6701aaac6e0125174e501 +module-graph.md: e28dc23d3baab10a5bffc6014339a6796daa845d +module-graph.zh.md: e32626810d7208cf534a164558dc0fec0dc3bf28 diff --git a/docs/module-graph.md b/docs/module-graph.md index f2c8a499fc..e28dc23d3b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1066,6 +1066,7 @@ flowchart TD pkg_subagent --> pkg_session_query pkg_subagent --> pkg_system_prompt pkg_subagent --> pkg_tools + pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session @@ -1302,6 +1303,7 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent pkg_api_remotes --> pkg_user_approval pkg_api_remotes --> pkg_user_questions pkg_client_ui_session --> pkg_api_session_controller @@ -1652,6 +1654,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_slots pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session + pkg_client_test_runtime --> pkg_subagent pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1848,7 +1851,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | @@ -1879,7 +1882,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1918,6 +1921,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ff0d0c7202..e32626810d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1068,6 +1068,7 @@ flowchart TD pkg_subagent --> pkg_session_query pkg_subagent --> pkg_system_prompt pkg_subagent --> pkg_tools + pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session @@ -1304,6 +1305,7 @@ flowchart TD pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent pkg_api_remotes --> pkg_user_approval pkg_api_remotes --> pkg_user_questions pkg_client_ui_session --> pkg_api_session_controller @@ -1654,6 +1656,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_slots pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session + pkg_client_test_runtime --> pkg_subagent pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1850,7 +1853,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | @@ -1881,7 +1884,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1920,6 +1923,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index e9ca531783..56d92e2097 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.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/subsystems/client-modules.md -client-modules.md: e80329be63c957407df5c9e06fd94780b66459bc -client-modules.zh.md: c42f04f3d8be53aa5e5d7cb3c57d06ccd5e21e72 +client-modules.md: 6188880fcd682c7c3c105764f212e92395444b61 +client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index e80329be63..6188880fcd 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -95,16 +95,12 @@ interface ClientArtifactBaseline { readonly mtimeMs: number /** Bundle size in bytes. */ readonly size: number - /** Source-map modification time, or null when no map was observable. */ - readonly mapMtimeMs: number | null - /** Source-map size in bytes, or null when no map was observable. */ - readonly mapSize: number | null } ``` -`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush. +`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle together with its current source map, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush. -In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files. +In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Source-map changes alone do not trigger a reload; the current map joins the snapshot when a bundle change does. Production graphs omit the HMR row entirely; the module host itself never watches files. diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index c42f04f3d8..a732758492 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -95,16 +95,12 @@ interface ClientArtifactBaseline { readonly mtimeMs: number /** Bundle size in bytes. */ readonly size: number - /** Source-map modification time, or null when no map was observable. */ - readonly mapMtimeMs: number | null - /** Source-map size in bytes, or null when no map was observable. */ - readonly mapSize: number | null } ``` -`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。 +`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它把 bundle 与当前 source map 一起重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。 -开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。 +开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。仅 source map 变化不会触发重载;bundle 变化时,当前 map 会一起进入快照。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 04a48b6ea7..d8a1c16e70 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/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/subsystems/persistence.md -persistence.md: 402836cb727fb99d92cea5e2a0d242b010aad2c9 -persistence.zh.md: 424fc928d5a8ef18b403ea31e5a5d26d3fd3fdc7 +persistence.md: f73b9ab01c232c4d4fec5aa51e5250c60b9337da +persistence.zh.md: 061c29f6b54c41137e3c764e9a7804f411f63f17 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 402836cb72..f73b9ab01c 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot { All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 18 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them. +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 19 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them. diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 424fc928d5..061c29f6b5 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 18 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。 +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 19 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 9e316a93c6..f4414983b1 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subsystems/subagent.md -subagent.md: e06d721427a195519817e4cfcf7c2bb38f572970 -subagent.zh.md: 0364360c783c1de1f6303b1a9cbc431f14db6dc2 +subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f +subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index e06d721427..f854711f11 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -648,6 +648,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +/** + * Remote face of {@link listChildren} for one browser: the durable listing + * plus live Agent activity and the delivery-time parent availability hint. + * Parent availability is a hint; {@link prompt} performs the authoritative + * check. Named apart from the provider-name {@link list}, which owns the + * member. + * @param parentSessionId - parent session whose direct children are listed. + * @param signal - carrier cancellation forwarded to Session queries. + * @returns the catalog view for that parent. + * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, + * `cancelled` for an aborted read, `subagent-projections-unavailable` when + * the deployment has no projection registry, otherwise `internal`. + */ +@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise + +/** + * Deliver one browser-authored message to a continuable child through the + * exact live direct parent, retaining the caller-minted request identity and + * validated browser zone on the accepted message. Success identifies the + * message the child's FIFO inbox accepted; later execution is independent of + * this call. + * @param request - durable address, minted identity, content, and optional browser zone. + * @param signal - carrier cancellation, owning the call until inbox acceptance. + * @returns the accepted message's inbox identity. + * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, + * `subagent-parent-unavailable`, `subagent-not-resumable`, + * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or + * `internal`. + */ +@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise + +/** + * Remote face of {@link interrupt} under one durable parent address. No + * catalog, history, persistence, or parent Agent lookup runs: the core + * primitive alone authorizes the address against the live Activation, which + * is what keeps a live child interruptible while its parent Agent is offline. + * Absent, idle, and already-completed targets are accepted no-ops there. + * @param childSessionId - durable child session id to interrupt. + * @param parentSessionId - durable direct parent whose authority is claimed. + * @param mode - required continuable-address discriminator. + * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, + * `subagent-unauthorized` when the address does not own the live target, + * otherwise `internal`. + */ +@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0364360c78..960d9b099d 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -652,6 +652,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +/** + * Remote face of {@link listChildren} for one browser: the durable listing + * plus live Agent activity and the delivery-time parent availability hint. + * Parent availability is a hint; {@link prompt} performs the authoritative + * check. Named apart from the provider-name {@link list}, which owns the + * member. + * @param parentSessionId - parent session whose direct children are listed. + * @param signal - carrier cancellation forwarded to Session queries. + * @returns the catalog view for that parent. + * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, + * `cancelled` for an aborted read, `subagent-projections-unavailable` when + * the deployment has no projection registry, otherwise `internal`. + */ +@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise + +/** + * Deliver one browser-authored message to a continuable child through the + * exact live direct parent, retaining the caller-minted request identity and + * validated browser zone on the accepted message. Success identifies the + * message the child's FIFO inbox accepted; later execution is independent of + * this call. + * @param request - durable address, minted identity, content, and optional browser zone. + * @param signal - carrier cancellation, owning the call until inbox acceptance. + * @returns the accepted message's inbox identity. + * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, + * `subagent-parent-unavailable`, `subagent-not-resumable`, + * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or + * `internal`. + */ +@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise + +/** + * Remote face of {@link interrupt} under one durable parent address. No + * catalog, history, persistence, or parent Agent lookup runs: the core + * primitive alone authorizes the address against the live Activation, which + * is what keeps a live child interruptible while its parent Agent is offline. + * Absent, idle, and already-completed targets are accepted no-ops there. + * @param childSessionId - durable child session id to interrupt. + * @param parentSessionId - durable direct parent whose authority is claimed. + * @param mode - required continuable-address discriminator. + * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, + * `subagent-unauthorized` when the address does not own the live target, + * otherwise `internal`. + */ +@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0724012e77..836c54e2ab 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^" }, @@ -97,6 +98,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^" } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 6956336198..14be075879 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' +import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -22,6 +23,8 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' +export type {} from '@deepseek-ai/dsh-subagent/remote' +export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' @@ -54,7 +57,6 @@ export type { MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -110,6 +112,7 @@ export type ClientFailure = | import('@deepseek-ai/dsh-client-connection/client').RpcError | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-subagent/client').SubagentControlError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError /** Success or failure returned by Client operations spanning both API families. */ @@ -138,7 +141,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - sessionRemote, workspaceRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index eb98174378..9d738aa3f8 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -54,6 +54,9 @@ { "path": "../../settings/settings" }, + { + "path": "../../subagent/subagent" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/api/session-controller/src/client/contract/result.ts b/packages/api/session-controller/src/client/contract/result.ts index 6577b63823..5306e611de 100644 --- a/packages/api/session-controller/src/client/contract/result.ts +++ b/packages/api/session-controller/src/client/contract/result.ts @@ -1,10 +1,11 @@ -/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */ +/** Client operation results spanning the Session and subagent Remote calls. */ import type { RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client' import type { SessionError } from '../../types.ts' /** Failure surfaced by the Client Session object layer. */ -export type ClientFailure = RpcError | SessionError +export type ClientFailure = RpcError | SessionError | SubagentControlError /** Success or failure returned by a Client Session operation. */ export type ClientResult = @@ -13,7 +14,7 @@ export type ClientResult = /** * Fold a rejected carrier operation into the Client Session failure vocabulary. - * @param error - rejection from a legacy subagent or local carrier call. + * @param error - rejection from a Remote or local carrier call. * @returns the failure branch of a Client Session result. */ export function transportResult(error: unknown): ClientResult { diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 6520e5fa08..7c8a90e662 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -5,7 +5,7 @@ * explicit act of widening what features may do to the sessions domain. */ import type { Context } from '@deepseek-ai/cordis' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { AgentContext } from '../scope.ts' diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index dff2317359..3f5f4ebd1c 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -2,7 +2,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 7fa6ede0d0..10a10261bf 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -73,6 +73,7 @@ export const inject = [ 'remote', 'remote.commands', 'remote.session', + 'remote.subagents', ] /** @@ -82,7 +83,7 @@ export const inject = [ export function apply(ctx: Context): void { const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes - const sessions = new ClientSessions(ctx, connection.api, remotes) + const sessions = new ClientSessions(ctx, remotes) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 1a76102eaa..9ad6d88f96 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -2,9 +2,7 @@ // dispatch entry + list state, constructed and held by ClientSessions (one per browser client). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { - IApiClient, SubagentAddress, SubagentCatalog, -} from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { @@ -145,11 +143,10 @@ export class SessionManager { }) /** - * @param api - shared wire client. + * @param remote - generated Remote namespaces the Session cluster calls. * @param restoredSelection - persisted real-Session selection candidate. */ constructor( - private readonly api: IApiClient, private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, @@ -321,7 +318,7 @@ export class SessionManager { const parentAvailable = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.parentAvailable - return new Session(sessionId, this.api, this.remote, { + return new Session(sessionId, this.remote, { ...(address === undefined ? {} : { address, ...catalogAvailability(parentAvailable), @@ -369,7 +366,7 @@ export class SessionManager { this.notifier.markDirty() const operation = (async () => { try { - const { result } = await this.api.subagents.list({ parentSessionId }) + const result = toSessionResult(await this.remote.subagents.list(parentSessionId)) if (result.ok) { const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride ?? result.value.parentAvailable diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index 1449cc2c83..bec8709b90 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -8,6 +8,9 @@ import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, +} from '@deepseek-ai/dsh-subagent/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemote } from '../transport.ts' @@ -21,9 +24,24 @@ export interface SessionCommandsRemote { ): Promise> } +/** Narrow subagent namespace consumed by a Client Session and its manager. */ +export interface SessionSubagentsRemote { + list(parentSessionId: SessionId, signal?: AbortSignal): Promise> + prompt( + request: SubagentPromptRequest, + signal?: AbortSignal, + ): Promise> + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: 'continuable', + ): Promise> +} + /** Generated Remote namespaces consumed by the Client Session object layer. */ export interface SessionRemotes { readonly $stream: ClientRemote['$stream'] readonly commands: SessionCommandsRemote readonly session: SessionRemote + readonly subagents: SessionSubagentsRemote } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 1dc484c615..a54d1ece0d 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -15,9 +15,7 @@ * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { - IApiClient, SubagentAddress, -} from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -218,12 +216,10 @@ export class ClientSessions implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. * @param remote - generated Remote namespaces shared with every Session. */ constructor( private readonly rootCtx: Context, - api: IApiClient, remote: SessionRemotes, ) { this.selection = createSnapshotStore( @@ -231,7 +227,6 @@ export class ClientSessions implements ISessions { { persist: { name: 'dsh.sessions.current' } }) const restored = this.selection.getSnapshot() this.manager = new SessionManager( - api, remote, restored.sessionId, restored.subagentAddress, diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..db7cf9a997 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -3,9 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { - IApiClient, SubagentAddress, -} from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { @@ -133,13 +131,11 @@ export class Session implements SessionFace { /** * @param sessionId - Host session identity (client sessions are always Host-born). - * @param api - shared wire client. * @param remote - generated Remote namespaces this session calls. * @param options - optional manager-owned state observers. */ constructor( readonly sessionId: SessionId, - private readonly api: IApiClient, private readonly remote: SessionRemotes, private readonly options: SessionOptions = {}, ) { @@ -222,13 +218,16 @@ export class Session implements SessionFace { }, } } else { - const routed = (await this.api.subagents.prompt({ - ...this.address, + const routed = toSessionResult(await this.remote.subagents.prompt({ + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: this.address.mode, content: content.flatMap(part => part.type === 'text' ? [{ type: 'text' as const, text: part.text }] : []), clientTimeZone: resolvedClientTimeZone(), - }, signal)).result + }, signal)) result = routed.ok ? { ok: true, value: { accepted: true } } : routed } } @@ -290,7 +289,7 @@ export class Session implements SessionFace { /** * Stop the active turn while the Host preserves pending inbox work; failures * land in promptError (same error-strip display slot). A continuable - * subagent address routes through `subagent.interrupt`, whose durable + * subagent address routes through `subagents.interruptByParent`, whose durable * parent-address authority works without a live parent Agent; a one-shot * address stays uncancellable (the UI offers no stop action, so this arm is * defensive). @@ -314,7 +313,11 @@ export class Session implements SessionFace { let result: ClientResult<{ accepted: true }> try { result = address !== undefined - ? (await this.api.subagents.interrupt(address)).result + ? toSessionResult(await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + address.mode, + )) : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) } catch (error) { result = transportResult(error) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 19cbf911f7..876786a759 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -85,6 +85,7 @@ async function mount(initialHost?: HostDescription): Promise { }) ctx.reflect.provide('remote.commands', remote.commands) ctx.reflect.provide('remote.session', remote.session) + ctx.reflect.provide('remote.subagents', remote.subagents) const fiber = ctx.plugin(SessionClient) await fiber const sessions = ctx.sessions as ClientSessions diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index b3aa0fe29d..1625ac65e7 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -2,8 +2,9 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { - IApiClient, + IApiClient, MessageId, RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry, + SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { @@ -20,7 +21,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/types' import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStream, RemoteStreamError, @@ -84,10 +85,20 @@ export function err(error: RpcError): RpcResponse { } /** Successful generated Remote result for programmable domain fakes. */ -function remoteOk(value: T): RemoteResult { +export function remoteOk(value: T): RemoteResult { return { ok: true, value } } +/** + * Failed generated Remote result carrying an owner's own failure vocabulary, + * which the carrier's closed RPC code set does not contain. + * @param error - the owner-declared failure. + * @returns the failure branch of a Remote result. + */ +export function remoteErr(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + type ValueStreamItem = | { kind: 'frame'; value: F; delivered?: () => void } | { kind: 'end' } @@ -189,19 +200,13 @@ export class FakeApiClient implements IApiClient { } lastSearchSignal: AbortSignal | undefined - onSubagentList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) - onSubagentPrompt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) + onSubagentList: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + onSubagentPrompt: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId })) - onSubagentInterrupt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) - - readonly subagents: IApiClient['subagents'] = { - list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), - prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), - interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), - } + onSubagentInterrupt: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ accepted: true as const })) readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), @@ -301,6 +306,19 @@ export class FakeApiClient implements IApiClient { follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), }, + subagents: { + list: parentSessionId => this.record( + 'subagents.list', + parentSessionId, + this.onSubagentList(parentSessionId), + ), + prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)), + interruptByParent: (childSessionId, parentSessionId, mode) => this.record( + 'subagents.interruptByParent', + { childSessionId, parentSessionId, mode }, + this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }), + ), + }, workspace: { create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index d271845e78..8b311de746 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -8,7 +8,7 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId @@ -29,14 +29,14 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) { function makeManager(): SessionManager { const api = new FakeApiClient() - return new SessionManager(api, fakeRemote(api)) + return new SessionManager(fakeRemote(api)) } describe('SessionManager instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever @@ -50,7 +50,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') @@ -66,7 +66,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const hydration = manager.refreshList() manager.handleSessionAdded(summary(S2, { blank: true })) first.resolve(ok({ items: [summary(S1)] as never[] })) @@ -83,7 +83,7 @@ describe('list lifecycle', () => { it('advances list activity from the filtered Host notification', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() manager.handleSessionActivity(S1, 500) @@ -93,7 +93,7 @@ describe('list lifecycle', () => { it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. @@ -102,7 +102,7 @@ describe('list lifecycle', () => { it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') @@ -121,7 +121,7 @@ describe('list lifecycle', () => { it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) @@ -129,7 +129,7 @@ describe('list lifecycle', () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const titleFrame = (title: string, seq: number) => { manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq }) } @@ -153,7 +153,7 @@ describe('list lifecycle', () => { it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, @@ -175,7 +175,7 @@ describe('list lifecycle', () => { 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[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) @@ -213,7 +213,7 @@ describe('search', () => { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -229,7 +229,7 @@ describe('search', () => { it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', @@ -252,7 +252,7 @@ describe('search', () => { describe('Host Remote event routing', () => { it('adds/removes/flips sessions and keeps removed instances resident', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) manager.handleSessionAdded(summary(S1, { blank: true })) manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) @@ -279,14 +279,14 @@ describe('subagent catalogs', () => { summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) - api.onSubagentList = () => Promise.resolve(ok({ + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -319,21 +319,23 @@ describe('subagent catalogs', () => { }, ]) expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagent.prompt')).toEqual([ + expect(api.callsOf('subagents.prompt')).toEqual([ { - parentSessionId: S1, childSessionId: S2, mode: 'continuable', + requestId: expect.any(String) as unknown as string, + parentSessionId: S1, childSessionId: S2, + mode: 'continuable', content: [{ type: 'text', text: 'continue' }], clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, }, ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) - const listCalls = api.callsOf('subagent.list').length + const listCalls = api.callsOf('subagents.list').length manager.handleSessionStatus(S2, false) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ kind: 'child', id: S2, activity: 'inactive', }) - expect(api.callsOf('subagent.list')).toHaveLength(listCalls) + expect(api.callsOf('subagents.list')).toHaveLength(listCalls) manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ @@ -351,20 +353,20 @@ describe('subagent catalogs', () => { vi.useFakeTimers() try { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() - const baseline = api.callsOf('subagent.list').length + const baseline = api.callsOf('subagents.list').length manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) - expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) + expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) manager.setSubagentCatalogOpen(S1, false) manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) - expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) + expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) } finally { vi.useRealTimers() } @@ -373,7 +375,7 @@ describe('subagent catalogs', () => { it('marks a loaded parent row expandable only for a direct subagent publication', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(ok({ + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -386,7 +388,7 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshSubagents(root) manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { @@ -405,13 +407,13 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { parentSessionId: S1, origin: 'subagent', })) - response.resolve(ok({ + response.resolve(remoteOk({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -424,7 +426,7 @@ describe('subagent catalogs', () => { { kind: 'child', id: S1, hasChildren: true }, ]) - api.onSubagentList = () => Promise.resolve(ok({ + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -442,12 +444,12 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) manager.handleSessionStatus(S1, false) manager.handleSessionStatus(S2, true) - response.resolve(ok({ + response.resolve(remoteOk({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'stopped', @@ -470,14 +472,14 @@ describe('subagent catalogs', () => { it('marks a detached catalog child inactive without requiring a selected address', async () => { const api = new FakeApiClient() - api.onSubagentList = () => Promise.resolve(ok({ + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshSubagents(S1) manager.handleSessionRemoved(S2) @@ -492,15 +494,15 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) - api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) - first.resolve(ok({ entries: [], parentAvailable: true })) + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + first.resolve(remoteOk({ entries: [], parentAvailable: true })) await refresh - expect(api.callsOf('subagent.list')).toHaveLength(1) + expect(api.callsOf('subagents.list')).toHaveLength(1) }) it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { @@ -511,7 +513,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote(api), root) + const manager = new SessionManager(fakeRemote(api), root) const refresh = manager.refreshSubagents(root) manager.setSubagentCatalogOpen(root, true) @@ -522,7 +524,7 @@ describe('subagent catalogs', () => { manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise - first.resolve(ok({ + first.resolve(remoteOk({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'older', activity: 'inactive', hasChildren: false, @@ -531,7 +533,7 @@ describe('subagent catalogs', () => { })) await refresh // The trailing pull is already in flight (kicked synchronously in finally). - second.resolve(ok({ + second.resolve(remoteOk({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'older', @@ -545,8 +547,10 @@ describe('subagent catalogs', () => { parentAvailable: true, })) await second.promise + // The Remote face resolves one microtask after the response settles. + await vi.advanceTimersByTimeAsync(0) - expect(api.callsOf('subagent.list')).toHaveLength(2) + expect(api.callsOf('subagents.list')).toHaveLength(2) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, label: 'older' }, { kind: 'child', id: S2, label: 'new child' }, @@ -565,9 +569,9 @@ describe('subagent catalogs', () => { }) const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) - first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) await refresh manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) @@ -579,12 +583,12 @@ describe('subagent catalogs', () => { manager.handleSessionRemoved(root) const trailing = deferred>>() api.onSubagentList = () => trailing.promise - mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) await midRefresh expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) - trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} })) await vi.waitFor(() => { expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ state: 'error', @@ -592,8 +596,7 @@ describe('subagent catalogs', () => { }) }) - const rootCalls = api.callsOf('subagent.list') - .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) + const rootCalls = api.callsOf('subagents.list').filter(call => call === root) expect(rootCalls).toHaveLength(3) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) @@ -602,14 +605,14 @@ describe('subagent catalogs', () => { it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(ok({ + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) @@ -625,14 +628,14 @@ describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() @@ -642,7 +645,7 @@ describe('remaining branches', () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) @@ -662,7 +665,7 @@ describe('remaining branches', () => { message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) @@ -676,7 +679,7 @@ describe('remaining branches', () => { message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -689,7 +692,7 @@ describe('remaining branches', () => { it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) @@ -704,7 +707,7 @@ describe('remaining branches', () => { it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -719,7 +722,7 @@ describe('remaining branches', () => { it('ignores Host status and error events for sessions without an instance', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) manager.handleSessionStatus(S2, true) manager.handleSessionError(S2, '无实例') }) @@ -727,7 +730,7 @@ describe('remaining branches', () => { it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() const before = manager.getListSnapshot() manager.handleSessionStatus(S2, true) @@ -743,7 +746,7 @@ describe('remaining branches', () => { it('carries parentSessionId from the added event into the lineage row', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) manager.handleSessionAdded(summary(S1, { blank: true })) manager.handleSessionAdded(summary(S2, { blank: true, parentSessionId: S1, origin: 'subagent', @@ -763,7 +766,7 @@ describe('connected generation', () => { hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened @@ -782,26 +785,19 @@ describe('connected generation', () => { } const parent = deferred>>() const child = deferred>>() - api.onSubagentList = payload => ( - (payload as { parentSessionId: SessionId }).parentSessionId === S1 - ? parent.promise - : child.promise - ) - const manager = new SessionManager(api, fakeRemote(api), S2, address) + api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise) + const manager = new SessionManager(fakeRemote(api), S2, address) manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(ok({ entries: [], parentAvailable: true })) - child.resolve(ok({ entries: [], parentAvailable: true })) + parent.resolve(remoteOk({ entries: [], parentAvailable: true })) + child.resolve(remoteOk({ entries: [], parentAvailable: true })) await vi.waitFor(() => { expect(api.callsOf('session.list')).toHaveLength(1) }) await vi.waitFor(() => { - expect(api.callsOf('subagent.list')).toEqual([ - { parentSessionId: S1 }, - { parentSessionId: S2 }, - ]) + expect(api.callsOf('subagents.list')).toEqual([S1, S2]) }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, @@ -882,7 +878,7 @@ describe('completed reminder', () => { it('a list refresh carrying the running→idle transition arms the reminder', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -894,7 +890,7 @@ describe('completed reminder', () => { it('never arms for sessions already idle at first observation', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -907,7 +903,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. @@ -921,7 +917,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 0a9700b960..64ee9d66f8 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -103,7 +103,7 @@ describe('Session projection value semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api)) + const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, @@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api)) + const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, @@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api)) + const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) @@ -141,7 +141,7 @@ describe('manager frame routing', () => { it('lands projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, }) @@ -156,7 +156,7 @@ describe('manager frame routing', () => { it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -208,7 +208,7 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote(api)) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index ed0a1568d1..6fc045cf70 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -48,12 +48,12 @@ function makeSession(): Session { function makeBench(): { api: FakeApiClient; session: Session } { const api = new FakeApiClient() - return { api, session: new Session(SID, api, fakeRemote(api)) } + return { api, session: new Session(SID, fakeRemote(api)) } } function makeManager(): SessionManager { const api = new FakeApiClient() - return new SessionManager(api, fakeRemote(api)) + return new SessionManager(fakeRemote(api)) } describe('Session queue snapshot intake', () => { @@ -210,7 +210,7 @@ describe('Session queue snapshot intake', () => { describe('queue operation transport', () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api)) + const session = new Session(SID, fakeRemote(api)) session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 6ee5bd9ba4..150e6813d1 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -5,7 +5,7 @@ import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' const SID = 'fk-s1' as SessionId @@ -19,7 +19,7 @@ function makeSession( api = new FakeApiClient(), options: SessionOptions = {}, ): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api, fakeRemote(api), options) } + return { api, session: new Session(SID, fakeRemote(api), options) } } function follow( @@ -239,7 +239,7 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api), { + const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -258,15 +258,17 @@ describe('prompt and cancel errors', () => { }, ]) expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagent.prompt')).toEqual([ + expect(api.callsOf('subagents.prompt')).toEqual([ { - parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', + requestId: expect.any(String) as unknown as string, + parentSessionId: PARENT, childSessionId: SID, + mode: 'continuable', content: [{ type: 'text', text: '继续' }], clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, }, ]) - expect(api.callsOf('subagent.interrupt')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + expect(api.callsOf('subagents.interruptByParent')).toEqual([ + { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) @@ -281,10 +283,10 @@ describe('prompt and cancel errors', () => { it('lands an interrupt business failure in promptError with op=stop', async () => { const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(err({ + api.onSubagentInterrupt = () => Promise.resolve(remoteErr({ code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, - }) as never) - const session = new Session(SID, api, fakeRemote(api), { + })) + const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -298,7 +300,7 @@ describe('prompt and cancel errors', () => { it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(api), { + const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) await session.open() @@ -316,8 +318,8 @@ describe('prompt and cancel errors', () => { }, ]) expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagent.prompt')).toEqual([]) - expect(api.callsOf('subagent.interrupt')).toEqual([]) + expect(api.callsOf('subagents.prompt')).toEqual([]) + expect(api.callsOf('subagents.interruptByParent')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 39f61a6663..4e4bade67c 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -18,6 +18,7 @@ import { err, fakeRemote, ok, + remoteOk, type RuntimeRemotes, } from './fake-api.client.ts' @@ -33,7 +34,7 @@ function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Be const ctx = new Context() const api = new FakeApiClient() const remote = fakeRemote(api) - const svc = new ClientSessions(ctx, api, configureRemote?.(remote) ?? remote) + const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote) return { ctx, api, svc } } @@ -522,9 +523,9 @@ describe('catalog-addressed navigation', () => { it('uses catalog labels for a listed addressed route', async () => { const b = bench() b.api.onSubagentList = (payload) => { - const { parentSessionId } = payload as { parentSessionId: SessionId } + const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(ok({ + return Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -533,7 +534,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(ok({ + return Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -541,7 +542,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(ok({ entries: [], parentAvailable: false })) + return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) } await feedList(b, [ { id: 'root' }, @@ -561,9 +562,9 @@ describe('catalog-addressed navigation', () => { it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => { const b = bench() b.api.onSubagentList = (payload) => { - const { parentSessionId } = payload as { parentSessionId: SessionId } + const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(ok({ + return Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -572,7 +573,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(ok({ + return Promise.resolve(remoteOk({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -580,7 +581,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(ok({ entries: [], parentAvailable: false })) + return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) } await feedList(b, [{ id: 'root' }]) await b.svc.refreshSubagents(sid('root')) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 648055bcee..219893c980 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -21,6 +21,7 @@ { "path": "../../llm/llm" }, { "path": "../../session/session-projection" }, { "path": "../../session/session-title" }, + { "path": "../../subagent/subagent" }, { "path": "../../util/brand" }, { "path": "../../util/crypto" }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a0ea093533..3a3c78dfa5 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -14,7 +14,6 @@ export type { ModelReasoningEffort, ModelSelection, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, - SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 756edaface..f3a20282ed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3260,13 +3260,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const api: ApiProxy = { - subagents: { - list: request => ok(request, { entries: [], parentAvailable: true }), - prompt: request => Promise.resolve(ok(request, { - messageId: `fixture-message-${request.payload.childSessionId}` as never, - })), - interrupt: request => Promise.resolve(ok(request, { accepted: true as const })), - }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, @@ -3469,6 +3462,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string)) case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string)) case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string)) + case 'subagents/list': return Promise.resolve({ + ok: true, + value: { entries: [], parentAvailable: true }, + }) + case 'subagents/prompt': return Promise.resolve({ + ok: true, + value: { + messageId: `fixture-message-${(request as { childSessionId: SessionId }).childSessionId}`, + }, + }) + case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } }) case 'session/list': return sessionApi.list( args._request as Parameters[0], ) @@ -3591,9 +3595,6 @@ export class FixtureApiClient extends AbstractApiClient { signal: AbortSignal, ): Promise> { switch (method) { - case 'subagent.list': return this.api.subagents.list(request) - case 'subagent.prompt': return this.api.subagents.prompt(request, signal) - case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d53c48d019..358b2f41cc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -35,7 +35,6 @@ export type { SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, - SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 90d042f171..93ab3af3b0 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -69,19 +69,6 @@ export class FakeApiClient implements IApiClient { private readonly generationConns: StreamConn[] = [] - readonly subagents: IApiClient['subagents'] = { - list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({ - entries: [], - parentAvailable: true, - }))), - prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ - messageId: 'fake-message' as never, - }))), - interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({ - accepted: true as const, - }))), - } - readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index df1a0ea553..047249b5b7 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/client/hmr/README.md -README.md: 88438279301d4a893f2c245a6580e63ea0ca7930 -README.zh.md: f56abdbb0473a850af933be0653e217fd51668de +README.md: de420ef2c809ccc13feea320ae3ffda3720f82a3 +README.zh.md: c7066e369b38fa3ffda6888831bba7780c5da8af diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 8843827930..de420ef2c8 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -59,7 +59,7 @@ This section explains how the reload chain is built; observable behavior is cove ### Design concept -The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. It also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames. +The chain is two halves with one contract: the node half owns bundle detection and notification, the browser half owns the swap. The node half runs one interval that stat-polls each graph bundle from the module host's pre-read baseline. An unchanged startup row starts watching without a content read or hash; a changed row, or a dirty row whose artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. `rebuilt()` reads the current source map together with the changed bundle; a map-only write does not reload executable code. The node half also serves `/plugins/events`, an SSE channel broadcasting `graph` and `rebuilt` frames. ### The browser swap diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index f56abdbb04..c7066e369b 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -59,7 +59,7 @@ kind: "package-reference" ### 设计理念 -链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle 及其可选 source map。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。它还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。 +链路分为两半,共用一份约定:node 半侧负责 bundle 检测与通知,浏览器半侧负责替换。node 半侧运行一个 interval,从 module host 读取文件前的基线开始 stat 轮询每个图 bundle。未变化的启动 row 无需读取内容或求 hash 即可开始监视;发生变化的 row,或产物恢复后的 dirty row,会进入 `rebuilt()`,且只广播真实 revision 变更。`rebuilt()` 会把当前 source map 与已变化的 bundle 一起读取;仅写入 map 不会重载可执行代码。node 半侧还提供 `/plugins/events`,一个广播 `graph` 与 `rebuilt` 帧的 SSE 通道。 ### 浏览器侧替换 diff --git a/packages/client/hmr/src/index.ts b/packages/client/hmr/src/index.ts index 1c169936bc..b06d3fa418 100644 --- a/packages/client/hmr/src/index.ts +++ b/packages/client/hmr/src/index.ts @@ -1,7 +1,7 @@ /** * HMR plugin, node half: the host end of the dev reload chain. One interval - * stat-polls every graph row's client bundle and optional source map (polling - * by design: network mounts deliver no inotify events), reports changes through + * stat-polls every graph row's client bundle (polling by design: network mounts + * deliver no inotify events), reports changes through * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel * broadcasting graph/rebuilt frames to the browser half (src/client/). * The web bundle mounts this row unconditionally: without a rebuild @@ -42,30 +42,22 @@ function sseData(frame: PluginsEventFrame): string { return `data: ${JSON.stringify(frame)}\n\n` } -type WatchedArtifactStat = Omit +type WatchedBundleStat = Omit type WatchedBundle = { -readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K] } & { dirty: boolean } -/** Snapshot the bundle plus its optional development source map. */ -function artifactStat(path: string): WatchedArtifactStat { +/** Snapshot the executable bundle metadata that drives reloads. */ +function bundleStat(path: string): WatchedBundleStat { const bundle = statSync(path) - try { - const map = statSync(`${path}.map`) - return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null } - } + return { mtimeMs: bundle.mtimeMs, size: bundle.size } } -/** Whether neither served artifact changed since the last successful re-hash. */ -function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean { +/** Whether the executable bundle is unchanged since the last successful re-hash. */ +function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean { return left.mtimeMs === right.mtimeMs && left.size === right.size - && left.mapMtimeMs === right.mapMtimeMs - && left.mapSize === right.mapSize } /** @@ -80,7 +72,7 @@ export function apply(ctx: Context, config: Config): void { // --- bundle watch: one HMR-owned stat poll ------------------------------ const watched = new Map() - const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => { + const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => { try { // rebuilt() replaces the opaque startup rev on its first call; later // calls stay silent when the content hash is unchanged. @@ -95,17 +87,15 @@ export function apply(ctx: Context, config: Config): void { } watch.mtimeMs = current.mtimeMs watch.size = current.size - watch.mapMtimeMs = current.mapMtimeMs - watch.mapSize = current.mapSize watch.dirty = false } const watchRow = (id: string, baseline: ClientArtifactBaseline): void => { const watch: WatchedBundle = { ...baseline, dirty: false } watched.set(id, watch) - let current: WatchedArtifactStat + let current: WatchedBundleStat try { - current = artifactStat(baseline.path) + current = bundleStat(baseline.path) } catch (error) { watch.dirty = true if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error) @@ -113,20 +103,20 @@ export function apply(ctx: Context, config: Config): void { } // The module host captured its baseline before reading the bytes in the // startup batch. Only a mismatch crosses into the content-hash path. - if (!sameArtifactStat(current, watch)) rehash(id, watch, current) + if (!sameBundleStat(current, watch)) rehash(id, watch, current) } const pollWatches = (): void => { for (const [id, watch] of watched) { - let current: WatchedArtifactStat + let current: WatchedBundleStat try { - current = artifactStat(watch.path) + current = bundleStat(watch.path) } catch (error) { watch.dirty = true if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error) continue } - if (!watch.dirty && sameArtifactStat(current, watch)) continue + if (!watch.dirty && sameBundleStat(current, watch)) continue // Stat-before-hash preserves a detectable older baseline for writes that // land during hashing. Repeated stat changes heal a torn read. rehash(id, watch, current) diff --git a/packages/client/hmr/tests/node-half.client.spec.ts b/packages/client/hmr/tests/node-half.client.spec.ts index 46bcd708d9..990eb6c4f6 100644 --- a/packages/client/hmr/tests/node-half.client.spec.ts +++ b/packages/client/hmr/tests/node-half.client.spec.ts @@ -31,19 +31,7 @@ interface FakeHostOptions { function artifactBaseline(path: string): ClientArtifactBaseline { const bundle = statSync(path) - try { - const sourceMap = statSync(`${path}.map`) - return { - path, - mtimeMs: bundle.mtimeMs, - size: bundle.size, - mapMtimeMs: sourceMap.mtimeMs, - mapSize: sourceMap.size, - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null } - } + return { path, mtimeMs: bundle.mtimeMs, size: bundle.size } } function fakeClientModuleHost(rows: Map, options: FakeHostOptions = {}): FakeHost { @@ -111,7 +99,7 @@ async function mount(clientModuleHost: FakeHost, webServer: WebServer) { } describe('hmr node half', () => { - it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => { + it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => { const bundle = join(dir, 'a.js') writeFileSync(bundle, 'v1') const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) @@ -130,13 +118,17 @@ describe('hmr node half', () => { clientModuleHost.rebuiltCalls.length = 0 await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) writeFileSync(`${bundle}.map`, '{"version":3}') + await new Promise(resolve => setTimeout(resolve, POLL_MS * 3)) + expect(clientModuleHost.rebuiltCalls).toEqual([]) + + writeFileSync(bundle, 'v3-even-longer') await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 }) await fiber.dispose() expect(routes).toHaveLength(0) // Watcher gone: further file changes report nothing. clientModuleHost.rebuiltCalls.length = 0 - writeFileSync(bundle, 'v3-even-longer') + writeFileSync(bundle, 'v4-after-dispose') await new Promise(resolve => setTimeout(resolve, POLL_MS * 4)) expect(clientModuleHost.rebuiltCalls).toHaveLength(0) }) diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 27f2d07363..44bfa54f61 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -23,7 +23,7 @@ */ import { createHash, randomBytes } from 'node:crypto' -import { readFileSync, statSync, type Stats } from 'node:fs' +import { readFileSync, statSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' @@ -78,10 +78,6 @@ export interface ClientArtifactBaseline { readonly mtimeMs: number /** Bundle size in bytes. */ readonly size: number - /** Source-map modification time, or null when no map was observable. */ - readonly mapMtimeMs: number | null - /** Source-map size in bytes, or null when no map was observable. */ - readonly mapSize: number | null } /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */ @@ -756,22 +752,13 @@ export class ClientModuleRegistry extends Service { return meta } - /** Capture the bundle and optional-map stats before reading their bytes. */ + /** Capture the bundle stats before reading its bytes. */ private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline { const bundle = statSync(clientPath) - let sourceMap: Stats | undefined - try { - sourceMap = statSync(`${clientPath}.map`) - } catch { - // Optional map metadata only seeds HMR; the following map read reports - // malformed or inaccessible bytes and a later stat change self-heals. - } return { path: clientPath, mtimeMs: bundle.mtimeMs, size: bundle.size, - mapMtimeMs: sourceMap?.mtimeMs ?? null, - mapSize: sourceMap?.size ?? null, } } diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 8452d6cd04..f55da4e017 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -378,8 +378,6 @@ describe('client bundle activation', () => { path: firstPath, mtimeMs: firstStat.mtimeMs, size: firstStat.size, - mapMtimeMs: null, - mapSize: null, }) expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined() }) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index b7051c61c3..e7e903ef3e 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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/client/ui-subagent/README.md -README.md: d77c6d0b8284047f88cb2c671c337328dce6b635 -README.zh.md: 0610500577c257af58bcaa077dd20a1fc0f09840 +README.md: 3c7d185123d76270844d17dcb0e070e27f33ef7d +README.zh.md: cf022bb0d121805f9b322e84fb77c6dac412fad0 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index d77c6d0b82..3c7d185123 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -33,7 +33,7 @@ Rows display mode plus `running`/`inactive` activity and an optional log-backed ### Continuing a conversation -A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagent.interrupt`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. +A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. ### The `@` reference source @@ -59,7 +59,7 @@ Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed ### Composer election -One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagent.prompt`. This package never receives host context or calls a model-facing tool. +One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 0610500577..cf022bb0d1 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 续接对话 -确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 +确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 ### `@` 引用 source @@ -59,7 +59,7 @@ token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累 ### 编辑器选举 -one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagent.prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 308a713b9f..6fa3da23a1 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -6,7 +6,7 @@ import { type SessionListState, type SessionProjectionMap, type SessionSummary, type SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, @@ -14,7 +14,6 @@ import { import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type {} from '@deepseek-ai/dsh-subagent/client' import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentHeaderLineage.module.css' import { indexSubagentDescendants } from './subagent-lineage.ts' diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 452123427c..a5a6f4f481 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -1,6 +1,6 @@ /** Web subagent catalog, navigation, and addressed-session composer owner. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx' diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index e3a80b3ee0..4c85625ce6 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { SessionListState, SessionSnapshot, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index ad9baff764..875709a409 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -185,7 +185,6 @@ class FakeApiClient implements IApiClient { onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) - declare readonly subagents: IApiClient['subagents'] declare readonly skills: IApiClient['skills'] declare readonly agentPresets: IApiClient['agentPresets'] declare readonly settings: IApiClient['settings'] diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e8f7ea93ef..84a89d325f 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -1152,4 +1152,5 @@ export class SessionStore extends Service { } +export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts' export default SessionStore diff --git a/packages/core/session/src/seq-ranges.ts b/packages/core/session/src/seq-ranges.ts new file mode 100644 index 0000000000..7b2605652c --- /dev/null +++ b/packages/core/session/src/seq-ranges.ts @@ -0,0 +1,70 @@ +/** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */ + +/** A stored source sequence or inclusive consecutive range. */ +export type EncodedSeq = number | [number, number] + +function isStrictlyIncreasing(values: readonly number[]): boolean { + return values.every((value, index) => index === 0 || value > (values[index - 1] as number)) +} + +/** + * Replace profitable consecutive runs with inclusive pairs. + * @param values - validated in-memory source sequences. + * @returns a lossless JSON storage form. + */ +export function encodeSeqRanges(values: readonly number[]): EncodedSeq[] { + if (!isStrictlyIncreasing(values)) return [...values] + const encoded: EncodedSeq[] = [] + for (let start = 0; start < values.length;) { + let end = start + while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1 + if (end - start >= 2) encoded.push([values[start] as number, values[end] as number]) + else for (let index = start; index <= end; index += 1) encoded.push(values[index] as number) + start = end + 1 + } + return encoded +} + +/** + * Expand a JSON storage-form source sequence array. + * @param value - parsed storage value. + * @param maxEntries - largest list permitted by the owning event. + * @returns the in-memory source sequences. + */ +export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): number[] { + if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array') + const decoded: number[] = [] + let hasRange = false + for (const entry of value) { + if (typeof entry === 'number') { + assertSeq(entry) + if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence') + decoded.push(entry) + continue + } + if (!Array.isArray(entry) || entry.length !== 2) { + throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs') + } + const start: unknown = entry[0] + const end: unknown = entry[1] + assertSeq(start) + assertSeq(end) + if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end') + const length = end - start + 1 + if (length > maxEntries - decoded.length) { + throw new TypeError('sourceEventSeqs range exceeds its event sequence') + } + for (let seq = start; seq <= end; seq += 1) decoded.push(seq) + hasRange = true + } + if (hasRange && !isStrictlyIncreasing(decoded)) { + throw new TypeError('sourceEventSeqs ranges must be strictly increasing') + } + return decoded +} + +function assertSeq(value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError('sourceEventSeqs must contain non-negative safe integers') + } +} diff --git a/packages/core/session/tests/seq-ranges.spec.ts b/packages/core/session/tests/seq-ranges.spec.ts new file mode 100644 index 0000000000..d86bb71e6c --- /dev/null +++ b/packages/core/session/tests/seq-ranges.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { decodeSeqRanges, encodeSeqRanges } from '@deepseek-ai/dsh-session' + +describe('sourceEventSeqs ranges', () => { + it.each([ + [], + [5], + [10, 11, 12, 13, 14], + [16, 17, 100, 200, 201, 202, 203], + [3, 2], + [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2], + ].map(values => [values]))('round-trips %j', (values) => { + expect(decodeSeqRanges(encodeSeqRanges(values))).toEqual(values) + }) + + it('encodes only profitable increasing runs', () => { + expect(encodeSeqRanges([1, 3, 4, 5, 7])).toEqual([1, [3, 5], 7]) + expect(encodeSeqRanges([1, 3, 4, 7])).toEqual([1, 3, 4, 7]) + expect(encodeSeqRanges([3, 2])).toEqual([3, 2]) + }) + + it('does not impose a persistence-only provenance length limit', () => { + const values = Array.from({ length: 1_000_001 }, (_, index) => index) + expect(encodeSeqRanges(values)).toEqual([[0, 1_000_000]]) + }) + + it('rejects malformed or impossible expansions', () => { + expect(() => decodeSeqRanges('nope')).toThrow(/must be an array/) + expect(() => decodeSeqRanges([-1])).toThrow(/non-negative safe integers/) + expect(() => decodeSeqRanges([[1]])).toThrow(/\[start, end\] pairs/) + expect(() => decodeSeqRanges([[4, 2]])).toThrow(/start <= end/) + expect(() => decodeSeqRanges([[2, 5], [4, 7]])).toThrow(/strictly increasing/) + expect(() => decodeSeqRanges([0], 0)).toThrow(/exceeds its event sequence/) + expect(() => decodeSeqRanges([[0, 10]], 10)).toThrow(/exceeds its event sequence/) + }) +}) diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index b27ec9dc2e..0eef8f761b 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/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/experimental/webworker-packer/README.md -README.md: 8464c2a6e442c6f6a88d6cf87ddd97de069ea37f -README.zh.md: c5a0c0e10c273dcd1477a906a7c2a14407034c2c +README.md: fd4f9793b17ad66ae4c5f1038ce027b2efe43abe +README.zh.md: 85e53445eb149fe55717eec27028f8285823af05 diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index 8464c2a6e4..fd4f9793b1 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -27,7 +27,7 @@ The pack is a three-layer standard stack: 1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain. 2. **Publish view** — each workspace or vendored package contributes its built npm slice (`files` through picomatch) without source or workspace `dist/`. External packages retain published JavaScript under both `src/` and `dist/` because their `main` or `exports` may point there; only generic test, map, declaration, and archive exclusions apply. -3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time. +3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. The transform reports statically named imports, re-exports, and dynamic imports; calls through `require`; and module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named import from `node:module` or `module`, including an import alias. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time. `repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. @@ -49,6 +49,7 @@ None; this package neither assembles nor sends a provider request. - **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change. +- **Reachability infers only exact request forms** — computed `import` and `require` arguments, stored `createRequire` results, CommonJS-obtained `createRequire`, and bases other than `import.meta.url` resolve only at runtime and fail loud if the target was otherwise pruned; a target reachable only through those forms needs an explicit image entry seed. - **Vendored package sources (`src/*.ts`) are excluded** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule. - **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first. diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index c5a0c0e10c..85e53445eb 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -27,7 +27,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为 1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。 2. **发布视图**——每个 workspace 或 vendored 包贡献其构建后的 npm 切片(`files` 走 picomatch),不带源码和 workspace `dist/`。外部包的 `main` 或 `exports` 可能指向 `src/` 或 `dist/`,因此两处发布 JavaScript 都会保留,只应用通用的测试、map、声明与归档排除规则。 -3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。 +3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。Transform 会报告具名静态 import、re-export 与动态 import、经 `require` 发起的调用,以及通过 `node:module` 或 `module` 具名导入(含导入别名)在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。 `repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 @@ -49,6 +49,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为 - **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。 +- **可达性只推断精确请求形式**——计算得到的 `import` 与 `require` 参数、保存下来的 `createRequire` 结果、经 CommonJS 获取的 `createRequire`,以及基准不是 `import.meta.url` 的调用只在运行时解析;若目标已被裁掉就会立即失败。只能通过这些形式触达的目标需要显式镜像入口种子。 - **vendored 包源码(`src/*.ts`)被排除**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。 - **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 86c892808d..6f293346ac 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: 9d00ec5b56c958149eceef34de16c36741ce0565 -README.zh.md: 04039d09ed60d0cf25d851dcd9767fd80b9a7221 +README.md: eaf467b8e4475908d5534113553c51182e30b7f2 +README.zh.md: 879f9a5d2d949296886ed6af1e6bf40bd42540d5 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 9d00ec5b56..eaf467b8e4 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 04039d09ed..879f9a5d2d 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -26,7 +26,7 @@ kind: "package-library" 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。 diff --git a/packages/experimental/webworker-runtime/src/compile/transform.ts b/packages/experimental/webworker-runtime/src/compile/transform.ts index b3aa229a49..9e3b652e65 100644 --- a/packages/experimental/webworker-runtime/src/compile/transform.ts +++ b/packages/experimental/webworker-runtime/src/compile/transform.ts @@ -70,6 +70,7 @@ class Transformer { private moduleSyntax = false private readonly moduleRequests = new Set() private readonly metaResolveRequests = new Set() + private readonly createRequireBindings = new Set() constructor(source: string, private readonly path: string) { // A `#!` line is only legal at offset zero, and the prologue takes that spot; @@ -154,7 +155,8 @@ class Transformer { if (Array.isArray(node.attributes) && node.attributes.length > 0) { this.fail('import attributes are not supported', node.start) } - const request = `require(${this.literal(node.source as Node)})` + const source = node.source as Node + const request = `require(${this.literal(source)})` const specifiers = node.specifiers as Node[] if (specifiers.length === 0) { this.replace(node.start, node.end, `${request};`) @@ -313,7 +315,12 @@ class Transformer { // --- traversal ------------------------------------------------------------ - private visit(node: unknown, context: { asyncGenerator: boolean; functionDepth: number; statement?: Node }): void { + private visit(node: unknown, context: { + asyncGenerator: boolean + functionDepth: number + moduleScope: boolean + statement?: Node + }): void { if (node === null || typeof node !== 'object') return if (Array.isArray(node)) { for (const child of node) this.visit(child, context) @@ -338,11 +345,12 @@ class Transformer { break } case 'CallExpression': { - // CommonJS bodies pass through untransformed, but their literal - // `require()` calls are module requests all the same. + // CommonJS bodies pass through untransformed, but literal calls through + // the wrapper's `require` remain module requests. The ESM case accepts + // only a direct module-scope createRequire call with the importer URL. const callee = record.callee as Node const callArguments = record.arguments as Node[] - if (callee.type === 'Identifier' && callee.name === 'require' && callArguments.length === 1 + if (this.isRequireCall(callee, context.moduleScope) && callArguments.length === 1 && typeof callArguments[0]?.value === 'string') { this.moduleRequests.add(callArguments[0].value) } @@ -382,6 +390,7 @@ class Transformer { if (context.functionDepth === 0) this.fail('a top-level for-await loop cannot run as CommonJS', record.start) this.forAwait(record) } + next = { ...next, moduleScope: false } break case 'LabeledStatement': { const body = record.body as Node @@ -399,8 +408,17 @@ class Transformer { next = { asyncGenerator: record.async === true && record.generator === true, functionDepth: context.functionDepth + 1, + moduleScope: false, } break + case 'BlockStatement': + case 'CatchClause': + case 'ClassBody': + case 'ForStatement': + case 'ForInStatement': + case 'SwitchStatement': + next = { ...next, moduleScope: false } + break default: break } if (record.type === 'ExpressionStatement') next = { ...next, statement: record } @@ -410,6 +428,40 @@ class Transformer { } } + private isCreateRequireCall(node: Node): boolean { + if (node.type !== 'CallExpression') return false + const callee = node.callee as Node + const args = node.arguments as Node[] + if (callee.type !== 'Identifier' || !this.createRequireBindings.has(nameOf(callee)) || args.length !== 1) { + return false + } + const base = args[0] as Node + if (base.type !== 'MemberExpression' || base.computed === true) return false + const object = base.object as Node + const property = base.property as Node + return object.type === 'MetaProperty' + && (object.meta as Node).name === 'import' + && property.type === 'Identifier' + && property.name === 'url' + } + + private isRequireCall(callee: Node, moduleScope: boolean): boolean { + return (callee.type === 'Identifier' && callee.name === 'require') + || (moduleScope && this.isCreateRequireCall(callee)) + } + + private indexCreateRequireImports(program: Node): void { + for (const statement of program.body as Node[]) { + if (statement.type !== 'ImportDeclaration') continue + const source = statement.source as Node + if (source.value !== 'node:module' && source.value !== 'module') continue + for (const specifier of statement.specifiers as Node[]) { + if (specifier.type !== 'ImportSpecifier' || nameOf(specifier.imported as Node) !== 'createRequire') continue + this.createRequireBindings.add(nameOf(specifier.local as Node)) + } + } + } + run(): string { // Transforming a lowered body again would nest the protocol inside itself: // it still runs, only slower and unreadable, so a mis-wired manifest must @@ -427,7 +479,8 @@ class Transformer { } catch (reason) { this.fail(`parse failed: ${(reason as Error).message}`, 0) } - this.visit(program, { asyncGenerator: false, functionDepth: 0 }) + this.indexCreateRequireImports(program) + this.visit(program, { asyncGenerator: false, functionDepth: 0, moduleScope: true }) if (this.edits.length === 0 && !this.moduleSyntax) return this.source const prologue: string[] = [] @@ -539,8 +592,9 @@ export interface LoweredModule { readonly lowered: boolean /** * Static module requests the body makes: import and re-export sources, - * literal dynamic imports, and literal `require()` calls. Computed requests - * are absent — they resolve (and fail loud) at runtime only. + * literal dynamic imports and calls through `require`, plus module-scope + * direct literal calls through an imported `createRequire(import.meta.url)`. + * Computed and rebased requests resolve (and fail loud) at runtime only. */ readonly moduleRequests: readonly string[] /** diff --git a/packages/experimental/webworker-runtime/src/node/globals/process.ts b/packages/experimental/webworker-runtime/src/node/globals/process.ts index 48c9c85899..5f7036be77 100644 --- a/packages/experimental/webworker-runtime/src/node/globals/process.ts +++ b/packages/experimental/webworker-runtime/src/node/globals/process.ts @@ -2,7 +2,8 @@ * The `process` global the worker needs before any VFS module runs. Cordis * reads `process.env` and `process.versions.node` while the Loader is * constructed, and `cordis.yml` keeps its `!!js process.*` expressions, so the - * configuration bytes stay identical to the Node deployment. + * configuration bytes stay identical to the Node deployment. Third-party Node + * packages use the presence of `process.title` to avoid browser-only globals. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/process */ import { requireActiveModuleLoader } from '../../module-system/module-loader.ts' @@ -23,6 +24,8 @@ export interface ProcessShim { readonly env: Record readonly argv: string[] readonly execArgv: string[] + /** Node process identity used by dependencies for environment detection. */ + readonly title: string /** * Node 22 `process.getBuiltinModule`: the worker's module proxy for a * builtin id (`fs`, `node:fs`), or undefined for anything else — it never @@ -86,6 +89,7 @@ export function installProcessGlobal(options: ProcessShimOptions): ProcessShim { env: { ...options.env }, argv: [...(options.argv ?? ['node', 'dsh-webworker'])], execArgv: [], + title: 'dsh-webworker', platform: 'linux', arch: 'x64', pid: 1, diff --git a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts index bf8b74a9bb..be5747ba53 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts @@ -144,6 +144,35 @@ check( check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n') } +{ + const direct = lowerModuleSource({ + filename: 'node_modules/p/direct.js', + source: "import { createRequire } from 'node:module'\ncreateRequire(import.meta.url)('external-package')\n", + }) + check('literal createRequire call is a module request', direct.moduleRequests, ['node:module', 'external-package']) + + const aliased = lowerModuleSource({ + filename: 'node_modules/p/aliased.js', + source: "makeRequire(import.meta.url)('aliased-package')\nimport { createRequire as makeRequire } from 'node:module'\n", + }) + check('aliased createRequire import is indexed before traversal', aliased.moduleRequests, ['aliased-package', 'node:module']) + + const runtimeOnly = lowerModuleSource({ + filename: 'node_modules/p/runtime-only.js', + source: [ + "import { createRequire } from 'node:module'", + 'const localRequire = createRequire(import.meta.url)', + "localRequire('stored')", + "createRequire(new URL('./other.js', import.meta.url))('rebased')", + "{ const createRequire = () => () => undefined; createRequire(import.meta.url)('block-shadowed') }", + "function load(createRequire) { createRequire(import.meta.url)('parameter-shadowed') }", + "for (const createRequire of []) createRequire(import.meta.url)('for-of-shadowed')", + "switch (0) { case 0: const createRequire = () => () => undefined; createRequire(import.meta.url)('switch-shadowed') }", + ].join('\n'), + }) + check('stored, rebased, and shadowed createRequire calls stay runtime-only', runtimeOnly.moduleRequests, ['node:module']) +} + // --------------------------------------------------------------------------- // 3. Import forms. // --------------------------------------------------------------------------- diff --git a/packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts b/packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts index e5d4f34e67..33f30e761d 100644 --- a/packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts @@ -19,6 +19,7 @@ describe('process shim', () => { const shim = installProcessGlobal({ cwd: '/dsh', env: { DSH_HOME: '/dsh/home' } }) expect(shim.cwd()).toBe('/dsh') expect(shim.env.DSH_HOME).toBe('/dsh/home') + expect(shim.title).toBe('dsh-webworker') // "0.0.0" keeps the vendored Loader off Node internals so the worker owns // the module seam. expect(shim.versions.node).toBe('0.0.0') diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7582815719..4f5da0ccc8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2073,6 +2073,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'children and per-candidate diagnostics with tree position, in stable pre-order.', throws: ['{@link SubagentError} under the same conditions as {@link listChildren}.'], }, + { + signature: '@Remote(\'list\') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise', + description: 'Remote face of listChildren for one browser: the durable listing plus live Agent activity and the delivery-time parent availability hint. Parent availability is a hint; prompt performs the authoritative check. Named apart from the provider-name list, which owns the member.', + parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'carrier cancellation forwarded to Session queries.' }], + returns: 'the catalog view for that parent.', + throws: ['{TypertRemoteFailure} `bad-request` for an empty parent id, `cancelled` for an aborted read, `subagent-projections-unavailable` when the deployment has no projection registry, otherwise `internal`.'], + }, + { + signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise', + description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call.', + parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }], + returns: 'the accepted message\'s inbox identity.', + throws: ['{TypertRemoteFailure} `bad-request`, `invalid-time-zone`, `subagent-parent-unavailable`, `subagent-not-resumable`, `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or `internal`.'], + }, + { + signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt', + description: 'Remote face of interrupt under one durable parent address. No catalog, history, persistence, or parent Agent lookup runs: the core primitive alone authorizes the address against the live Activation, which is what keeps a live child interruptible while its parent Agent is offline. Absent, idle, and already-completed targets are accepted no-ops there.', + parameters: [{ name: 'childSessionId', description: 'durable child session id to interrupt.' }, { name: 'parentSessionId', description: 'durable direct parent whose authority is claimed.' }, { name: 'mode', description: 'required continuable-address discriminator.' }], + returns: 'acknowledgement that the cancel signal was admitted, not that the target is quiescent.', + throws: ['{TypertRemoteFailure} `bad-request` for an empty id, `subagent-unauthorized` when the address does not own the live target, otherwise `internal`.'], + }, { signature: 'registerProvider(provider: SubagentProvider): () => void', description: 'Register a provider under its name. Registration is effect-scoped and HMR safe; removing a provider blocks new starts but does not revoke runs that were already returned to their holders.', @@ -3413,7 +3434,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientArtifactBaseline', - declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}', + declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}', }, { name: 'CodeBindingErrorClass', @@ -4401,7 +4422,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'subagent-parent-unavailable\': {\n parentSessionId: SessionId;\n };\n \'subagent-not-found\': {\n parentSessionId: SessionId;\n childSessionId: SessionId;\n };\n \'subagent-catalog-diagnostic\': {\n parentSessionId: SessionId;\n childS /* …truncated — full shape in source */', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', @@ -5063,6 +5084,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, + { + name: 'SubagentCatalog', + declaration: 'export interface SubagentCatalog {\n readonly entries: readonly SubagentListEntry[];\n readonly parentAvailable: boolean;\n}', + }, { name: 'SubagentDescendantListEntry', declaration: 'export type SubagentDescendantListEntry = SubagentListEntry & {\n readonly parentId: SessionId;\n readonly depth: number;\n};', @@ -5079,6 +5104,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentInterruptAuthority', declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};', }, + { + name: 'SubagentInterruptReceipt', + declaration: 'export interface SubagentInterruptReceipt {\n readonly accepted: true;\n}', + }, + { + name: 'SubagentListEntry', + declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', + }, + { + name: 'SubagentPromptReceipt', + declaration: 'export interface SubagentPromptReceipt {\n readonly messageId: MessageId;\n}', + }, + { + name: 'SubagentPromptRequest', + declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: ContentBlock[];\n readonly clientTimeZone?: string;\n}', + }, + { + name: 'SubagentPromptRequestId', + declaration: 'export type SubagentPromptRequestId = Branded<\'session-request-id\'>;', + }, { name: 'SubagentProvider', declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start(request: ResolvedSubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', @@ -5113,7 +5158,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | un /* …truncated — full shape in source */', }, { name: 'SubagentStartRequest', @@ -5495,6 +5540,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertEventModel', declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}', }, + { + name: 'TypertGatewayBinding', + declaration: 'export interface TypertGatewayBinding {\n readonly service: Service;\n readonly serviceKey: string;\n readonly namespace: string;\n}', + }, { name: 'TypertGatewayWireStream', declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', @@ -5543,6 +5592,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertRemoteEventSource', declaration: 'export type TypertRemoteEventSource = (signal: AbortSignal) => AsyncIterable;', }, + { + name: 'TypertRemoteService', + declaration: 'export abstract class TypertRemoteService extends Service {\n readonly typertRemote: TypertGatewayBinding;\n}', + }, { name: 'TypertSchemaFilter', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4533eff53d..209e601d83 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 3e11f00400a2f954a27ce957bd2bf95f8bd8932d -README.zh.md: aea58628e40efad282daf5ddbc412e0cf8471872 +README.md: 3460d0ec021f658fe91e1ed8b617196d437cb937 +README.zh.md: a6cfac365fe728f26b3afd22ef32e661bd0146ff diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3e11f00400..3460d0ec02 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, subagents, workspaces, directory picking, model selection, agent presets, skills, goals, settings, credentials, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below. +Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, workspaces, directory picking, model selection, agent presets, skills, goals, settings, credentials, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below. ## Table of Contents @@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `subagents`, `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. +The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. ### Sessions and history @@ -92,7 +92,7 @@ The package is built on one separation: the API contract is channel-independent, ### The gateway service -`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, subagents, workspace registry, directory picker, agent presets, settings, credentials, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package. +`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, workspace registry, directory picker, agent presets, settings, credentials, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ### Request flow diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index aea58628e4..a6cfac365f 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、子代理、工作区、目录选择、模型选择、agent preset、skill、目标、设置、凭据、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。 +web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、工作区、目录选择、模型选择、agent preset、skill、目标、设置、凭据、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。 ## 目录 @@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`subagents`、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 +API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 ### 会话与历史 @@ -92,7 +92,7 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q ### 网关服务 -`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、子代理、工作区注册表、目录选择器、agent preset、设置、凭据、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 +`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、工作区注册表、目录选择器、agent preset、设置、凭据、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ### 请求流 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 959b35ed9f..36f0ae707c 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -61,7 +61,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "fflate": "^0.8.2", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 857172bcc2..3c3f8210d6 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type { SessionId } from '@deepseek-ai/dsh-session' -import { SubagentError } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import { InvalidPresetIdError, PresetExistsError, @@ -19,7 +18,6 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, SettingsNamespaceView, } from './api/index.ts' -import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { @@ -46,25 +44,6 @@ import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - -/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */ -function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the RPC maps that parser rejection below. - return undefined - } -} - /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted @@ -110,55 +89,6 @@ export interface ApiProxyDefaults { canOpenPath?: () => boolean } -/** Map continuation admission failures without exposing provider details. */ -function subagentPromptError( - request: RpcRequest<{ childSessionId: SessionId }>, - error: unknown, - signal: AbortSignal, -): RpcResponse { - const childSessionId = request.payload.childSessionId - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} }) - } - if (error instanceof SubagentError) { - switch (error.code) { - case 'NOT_RESUMABLE': - return err(request, { - code: 'subagent-not-resumable', - message: 'subagent cannot be resumed', - details: { childSessionId }, - }) - case 'UNAUTHORIZED': - return err(request, { - code: 'subagent-unauthorized', - message: 'subagent does not belong to this parent', - details: { childSessionId }, - }) - case 'DRAINING': - case 'ACTIVATION_CLOSING': - case 'CONTINUATION_UNAVAILABLE': - case 'PERSISTENCE_UNAVAILABLE': - return err(request, { - code: 'subagent-delivery-unavailable', - message: 'subagent follow-up is temporarily unavailable', - details: { childSessionId }, - }) - default: - break - } - } - return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) -} - -/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ -function projectionsUnavailableError(): RpcError { - return { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - details: {}, - } -} - /** The roster is absent: this deployment composes no agent presets at all. */ function noRoster(agentPreset: string): RpcError { return { @@ -341,99 +271,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return { - subagents: { - async list(request, signal) { - try { - const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal) - return ok(request, { - entries: entries.map(entry => entry.kind === 'child' - ? { - ...entry, - activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive', - } - : entry), - parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined, - }) - } catch (error: unknown) { - if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { - return err(request, { - code: 'cancelled', - message: 'subagent catalog read was cancelled', - details: {}, - }) - } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return err(request, projectionsUnavailableError()) - } - return err(request, { - code: 'internal', - message: 'subagent catalog read failed', - details: {}, - }) - } - }, - - async prompt(request, signal) { - const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload - const canonicalTimeZone = clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(clientTimeZone) - if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return err(request, { - code: 'invalid-time-zone', - message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', - details: { value: clientTimeZone }, - }) - } - const parent = ctx.agents.get(parentSessionId) - if (parent === undefined) { - return err(request, { - code: 'subagent-parent-unavailable', - message: `parent session "${parentSessionId}" is not live`, - details: { parentSessionId }, - }) - } - try { - const messageId = await ctx.subagents.followup(parent, childSessionId, content, { - source: { - kind: 'user', - rpcId: request.rpcId as unknown as SessionRequestId, - ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), - }, - signal, - }) - return ok(request, { messageId }) - } catch (error: unknown) { - return subagentPromptError(request, error, signal) - } - }, - - // Deliberately no catalog, history, persistence, or parent Agent lookup: - // the core primitive alone authorizes the durable address against the - // live Activation, which is what keeps a live child interruptible while - // its parent Agent is offline. Absent targets are accepted no-ops there. - interrupt(request) { - const { parentSessionId, childSessionId } = request.payload - try { - ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId }) - } catch (error: unknown) { - if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { - return Promise.resolve(err(request, { - code: 'subagent-unauthorized', - message: 'subagent does not belong to this parent', - details: { childSessionId }, - })) - } - return Promise.resolve(err(request, { - code: 'internal', - message: 'subagent interrupt failed', - details: {}, - })) - } - return Promise.resolve(ok(request, { accepted: true as const })) - }, - }, - host: { describe(request) { // TODO(apiproxy-version): read the version from apps/cli/package.json. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index be660ec53b..434c362e1a 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { SubagentsApi } from './subagents.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' @@ -15,7 +14,6 @@ import type { DownloadsApi } from './downloads.ts' /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { - subagents: SubagentsApi host: HostApi skills: SkillsApi agentPresets: AgentPresetsApi @@ -32,10 +30,6 @@ export type { ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' -export type { - SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, - SubagentPromptReceipt, SubagentsApi, -} from './subagents.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ad37ade1b8..d636963e82 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,7 +9,6 @@ import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' -import type { SubagentsApi } from './subagents.ts' import type { RpcResponse } from './rpc.ts' /** @@ -18,9 +17,6 @@ import type { RpcResponse } from './rpc.ts' * request; the carrier passes its request signal, never a wire field. */ export interface RpcMethodMap { - 'subagent.list': SubagentsApi['list'] - 'subagent.prompt': SubagentsApi['prompt'] - 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.listDirectory': HostApi['listDirectory'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 9dacda7237..498c7c3a1f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -49,16 +49,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), - z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({ - parentSessionId: z.string(), - childSessionId: z.string(), - reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]), - }) }), - z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fbbdbe63d0..b154a5e14a 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -62,16 +62,6 @@ export interface RpcErrorDetailsMap { * details name the endpoint asked, never the credential offered. */ 'model-discovery-failed': { settingsNs: string; baseURL?: string } - 'subagent-parent-unavailable': { parentSessionId: SessionId } - 'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId } - 'subagent-catalog-diagnostic': { - parentSessionId: SessionId - childSessionId: SessionId - reason: 'corrupt' | 'unsupported' | 'unavailable' - } - 'subagent-not-resumable': { childSessionId: SessionId } - 'subagent-unauthorized': { childSessionId: SessionId } - 'subagent-delivery-unavailable': { childSessionId: SessionId } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts deleted file mode 100644 index 0943c6c79f..0000000000 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** Zod schemas for the browser-safe subagent domain. */ - -import { z } from 'zod' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './ids.schema.ts' -import type { SubagentListEntry } from './subagents.ts' - -const contentBlockSchema = z.looseObject({ type: z.string() }) - -/** Healthy and diagnostic durable catalog rows. */ -export const subagentListEntrySchema = z.union([ - z.object({ - kind: z.literal('child'), - id: sessionIdSchema, - mode: z.literal('one-shot'), - activity: z.union([z.literal('running'), z.literal('inactive')]), - hasChildren: z.boolean(), - label: z.string().optional(), - }), - z.object({ - kind: z.literal('child'), - id: sessionIdSchema, - mode: z.literal('continuable'), - activity: z.union([z.literal('running'), z.literal('inactive')]), - hasChildren: z.boolean(), - label: z.string(), - }), - z.object({ - kind: z.literal('diagnostic'), - id: sessionIdSchema, - reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]), - }), -]) satisfies z.ZodType> - -/** subagent.list request payload. */ -export const subagentListRequestSchema = z.object({ - parentSessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** subagent.list response value. */ -export const subagentListValueSchema = z.object({ - entries: z.array(subagentListEntrySchema), - parentAvailable: z.boolean(), -}) satisfies z.ZodType>> - -/** subagent.prompt request payload. */ -export const subagentPromptRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.literal('continuable'), - content: z.array(contentBlockSchema), - clientTimeZone: z.string().optional(), -}) as unknown as z.ZodType> - -/** subagent.interrupt request payload. */ -export const subagentInterruptRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.literal('continuable'), -}) satisfies z.ZodType>> - -/** subagent.interrupt response value. */ -export const subagentInterruptValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> - -const messageIdSchema = z.string() as unknown as z.ZodType - -/** subagent.prompt response value. */ -export const subagentPromptValueSchema = z.object({ - messageId: messageIdSchema, -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts deleted file mode 100644 index 2ca814066b..0000000000 --- a/packages/host/apiproxy/src/api/subagents.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** Browser-safe subagent catalog, continuation, and interrupt contract. */ - -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Complete durable direct-child catalog row. */ -export type SubagentListEntry = - | { - kind: 'child' - id: SessionId - /** Whether the child Agent driver is running at the Host sampling boundary. */ - activity: 'running' | 'inactive' - /** Whether a direct descendant has durable `origin: 'subagent'`. */ - hasChildren: boolean - } & ( - | { - mode: 'one-shot' - label?: string - } - | { - mode: 'continuable' - label: string - } - ) - | { - kind: 'diagnostic' - id: SessionId - reason: 'corrupt' | 'unsupported' | 'unavailable' - } - -/** Inbox identity returned once the continuation accepts one human message. */ -export interface SubagentPromptReceipt { - messageId: MessageId -} - -/** Uniform acknowledgement that one interrupt request was admitted. */ -export interface SubagentInterruptReceipt { - accepted: true -} - -/** Durable parent/child address that selects subagent transport in the client. */ -export type SubagentAddress = - & { - parentSessionId: SessionId - childSessionId: SessionId - } - & ( - | { mode: 'one-shot' } - | { mode: 'continuable' } - ) - -/** Complete direct-child catalog plus the delivery-time parent availability hint. */ -export interface SubagentCatalog { - entries: SubagentListEntry[] - parentAvailable: boolean -} - -/** Subagent-domain unary methods. */ -export interface SubagentsApi { - /** - * Lists direct session-backed children without loading either side. Parent - * availability is a hint; continuable prompt performs the authoritative - * check. - */ - list( - request: RpcRequest<{ parentSessionId: SessionId }>, - signal?: AbortSignal, - ): Promise> - - /** - * Delivers human content to a continuable child through the exact live - * parent's continuation owner. Success identifies the message accepted by - * the child's FIFO inbox; later execution is independent of this request. - * Optional browser-zone provenance is validated and logged on that message. - */ - prompt( - request: RpcRequest< - Extract & { - content: ContentBlock[] - /** Optional browser zone sampled for this exact human prompt. */ - clientTimeZone?: string - } - >, - signal: AbortSignal, - ): Promise> - - /** - * Interrupts a live continuable child's current turn under the address's - * durable direct-parent authority, without requiring a live parent Agent, - * consulting the catalog, or resuming anything. Fire-and-return: `accepted` - * acknowledges the admitted cancel signal, not target quiescence, so the - * child may remain visibly running briefly. Unclaimed queued follow-ups are - * kept and parked; an absent, idle, or already-completed target is likewise - * `accepted`. - */ - interrupt( - request: RpcRequest>, - ): Promise> -} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 21a89483dc..f65d5fafff 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -28,11 +28,6 @@ import { credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, } from '../api/credentials.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' -import { - subagentInterruptValueSchema, - subagentListValueSchema, - subagentPromptValueSchema, -} from '../api/subagents.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -47,11 +42,6 @@ import { * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. */ export interface IApiClient { - subagents: { - list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> - prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> - interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> - } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> @@ -89,9 +79,6 @@ export interface IApiClient { * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'subagent.list': subagentListValueSchema, - 'subagent.prompt': subagentPromptValueSchema, - 'subagent.interrupt': subagentInterruptValueSchema, 'host.describe': hostDescribeValueSchema, 'host.pickDirectory': hostPickDirectoryValueSchema, 'host.listDirectory': hostListDirectoryValueSchema, @@ -240,12 +227,6 @@ export abstract class AbstractApiClient implements IApiClient { // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - readonly subagents: IApiClient['subagents'] = { - list: (payload, signal) => this.callUnary('subagent.list', payload, signal), - prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), - interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), - } - readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), // A native system dialog is user-paced and may legitimately stay open diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1451741fc2..6b3a7d3b13 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -31,11 +31,6 @@ import { credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, } from '../api/credentials.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' -import { - subagentInterruptRequestSchema, - subagentListRequestSchema, - subagentPromptRequestSchema, -} from '../api/subagents.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -54,9 +49,6 @@ type UnaryRoutes = { } const UNARY_ROUTES: UnaryRoutes = { - 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, - 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, - 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 98ee7eca20..aab8e952d2 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -62,7 +62,7 @@ export interface Config { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', + 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery', 'sessionController', ] @@ -72,7 +72,6 @@ export class ApiProxyService extends Service implements ApiProxy { .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, }) - readonly subagents: ApiProxy['subagents'] readonly host: ApiProxy['host'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -91,7 +90,6 @@ export class ApiProxyService extends Service implements ApiProxy { ? {} : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), }) - this.subagents = api.subagents this.host = api.host this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts deleted file mode 100644 index 0ba2a216b3..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { SubagentError } from '@deepseek-ai/dsh-subagent' -import { RpcId } from '../src/api/rpc.ts' -import type { RpcRequest } from '../src/api/rpc.ts' -import { createApiProxy } from '../src/api-proxy.ts' - -const sid = (value: string): SessionId => value as SessionId -const PARENT = sid('parent') -const CHILD = sid('child') - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId('subagent-rpc'), payload } -} - -function bench(options: { - parentLive?: boolean - childStatus?: 'idle' | 'running' - entries?: object[] - followupError?: Error - interruptError?: Error - listError?: Error -} = {}) { - const parent = { id: PARENT } - const child = options.childStatus === undefined - ? undefined - : { id: CHILD, status: options.childStatus } - const getAgent = vi.fn((id: SessionId) => { - if (options.parentLive !== false && id === PARENT) return parent - if (id === CHILD) return child - return undefined - }) - const listChildren = vi.fn(() => options.listError === undefined - ? Promise.resolve(options.entries ?? [ - { - kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', - activity: 'inactive', hasChildren: false, - }, - ]) - : Promise.reject(options.listError)) - const followup = vi.fn(( - _parent: unknown, - _childId: SessionId, - _content: unknown, - _delivery: { - source: { kind: string; rpcId: RpcId; clientTimeZone?: string } - signal: AbortSignal - }, - ) => options.followupError === undefined - ? Promise.resolve('message-1') - : Promise.reject(options.followupError)) - const interrupt = vi.fn(( - _targetSessionId: SessionId, - _authority: { kind: 'user'; parentSessionId: SessionId }, - ) => { - if (options.interruptError !== undefined) throw options.interruptError - }) - const ctx = new Context() - ctx.provide('agents', { get: getAgent }) - ctx.provide('subagents', { listChildren, followup, interrupt }) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', - }) - return { api, getAgent, listChildren, followup, interrupt, parent } -} - -describe('subagent gateway', () => { - it('lists the complete catalog and reports exact live-parent availability', async () => { - const { api, listChildren } = bench({ parentLive: false, entries: [ - { - kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', - activity: 'inactive', hasChildren: true, - }, - { - kind: 'child', id: sid('one-shot'), mode: 'one-shot', - activity: 'inactive', hasChildren: false, - }, - { kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' }, - ] }) - const response = await api.subagents.list(request({ parentSessionId: PARENT })) - expect(response.rpcId).toBe('subagent-rpc') - expect(response.result).toMatchObject({ - ok: true, - value: { - parentAvailable: false, - entries: [ - { kind: 'child', mode: 'continuable' }, - { kind: 'child', mode: 'one-shot' }, - { kind: 'diagnostic' }, - ], - }, - }) - expect(listChildren).toHaveBeenCalledWith(PARENT, undefined) - }) - - it('derives catalog activity from the live child Agent rather than Session residency', async () => { - const residentIdle = bench({ childStatus: 'idle', entries: [{ - kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', - activity: 'running', hasChildren: false, - }] }) - expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result) - .toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } }) - - const running = bench({ childStatus: 'running' }) - expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result) - .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) - }) - - it('maps missing catalog projections on list without preflighting prompt delivery', async () => { - const listError = () => new SubagentError( - 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', - ) - const expected = { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - } - - const list = bench({ listError: listError() }) - expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) - .toMatchObject({ ok: false, error: expected }) - - const prompt = bench({ listError: listError() }) - expect((await prompt.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ ok: true }) - expect(prompt.listChildren).not.toHaveBeenCalled() - expect(prompt.followup).toHaveBeenCalledOnce() - }) - - it('routes human content through the exact live parent with rpc attribution', async () => { - const { api, parent, followup } = bench() - const content = [{ type: 'text' as const, text: '继续' }] - const signal = new AbortController().signal - const response = await api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content, - }), signal) - expect(response.result).toMatchObject({ - ok: true, value: { messageId: 'message-1' }, - }) - expect(followup).toHaveBeenCalledWith( - parent, - CHILD, - content, - { source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal }, - ) - }) - - it('canonicalizes browser-zone provenance before delivering a child prompt', async () => { - const { api, parent, followup } = bench() - const alias = 'US/Pacific' - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) - .resolvedOptions().timeZone - const content = [{ type: 'text' as const, text: 'continue locally' }] - const signal = new AbortController().signal - await expect(api.subagents.prompt(request({ - parentSessionId: PARENT, - childSessionId: CHILD, - mode: 'continuable', - content, - clientTimeZone: alias, - }), signal)).resolves.toMatchObject({ result: { ok: true } }) - expect(followup).toHaveBeenCalledWith(parent, CHILD, content, { - source: { kind: 'user', rpcId: RpcId('subagent-rpc'), clientTimeZone: canonical }, - signal, - }) - - const invalid = await api.subagents.prompt(request({ - parentSessionId: PARENT, - childSessionId: CHILD, - mode: 'continuable', - content, - clientTimeZone: 'Not/A_Real_Zone', - }), signal) - expect(invalid.result).toEqual({ - ok: false, - error: { - code: 'invalid-time-zone', - message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', - details: { value: 'Not/A_Real_Zone' }, - }, - }) - expect(followup).toHaveBeenCalledOnce() - }) - - it('fails before delivery when the parent is absent and maps continuation failures', async () => { - const absent = bench({ parentLive: false }) - expect((await absent.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ - ok: false, error: { code: 'subagent-parent-unavailable' }, - }) - expect(absent.listChildren).not.toHaveBeenCalled() - - const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') }) - expect((await failed.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ - ok: false, error: { code: 'subagent-delivery-unavailable' }, - }) - }) - - it('hides unexpected backend details', async () => { - const catalog = bench({ listError: new Error('secret descriptor') }) - expect((await catalog.api.subagents.list(request({ - parentSessionId: PARENT, - }))).result).toMatchObject({ - ok: false, - error: { code: 'internal', message: 'subagent catalog read failed' }, - }) - - const prompt = bench({ followupError: new Error('secret provider') }) - expect((await prompt.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ - ok: false, - error: { code: 'internal', message: 'subagent prompt failed' }, - }) - }) - - it('interrupts through the core primitive alone while the parent Agent is offline', async () => { - const { api, interrupt, getAgent, listChildren } = bench({ parentLive: false }) - const response = await api.subagents.interrupt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, - })) - expect(response.rpcId).toBe('subagent-rpc') - expect(response.result).toEqual({ ok: true, value: { accepted: true } }) - expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT }) - // No parent-registry or catalog dependency: this is what keeps a - // live child interruptible after its parent Agent went offline. - expect(getAgent).not.toHaveBeenCalled() - expect(listChildren).not.toHaveBeenCalled() - }) - - it('maps interrupt authorization rejection without touching other services', async () => { - const { api, listChildren } = bench({ - interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'), - }) - const response = await api.subagents.interrupt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, - })) - expect(response.result).toEqual({ - ok: false, - error: { - code: 'subagent-unauthorized', - message: 'subagent does not belong to this parent', - details: { childSessionId: CHILD }, - }, - }) - expect(listChildren).not.toHaveBeenCalled() - }) - - it('hides unexpected interrupt failures behind the internal code', async () => { - const { api } = bench({ interruptError: new Error('secret activation state') }) - const response = await api.subagents.interrupt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, - })) - expect(response.result).toEqual({ - ok: false, - error: { code: 'internal', message: 'subagent interrupt failed', details: {} }, - }) - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1911eec99d..d6be6bf619 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -6,19 +6,15 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-session' import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -const sid = (id: string): SessionId => id as SessionId - function ok(request: RpcRequest, value: T): Promise> { return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) } /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ function scriptedApi(overrides: { - subagents?: Partial host?: Partial skills?: Partial agentPresets?: Partial @@ -29,12 +25,6 @@ function scriptedApi(overrides: { const err = (r: RpcRequest): Promise> => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } }) return { - subagents: { - list: r => ok(r, { entries: [], parentAvailable: false }), - prompt: r => ok(r, { messageId: 'message-1' as never }), - interrupt: r => ok(r, { accepted: true as const }), - ...overrides.subagents, - }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, @@ -140,32 +130,6 @@ describe('unary round trip', () => { await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) }) - it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => { - const interrupt = vi.fn((r: RpcRequest) => ok(r, { accepted: true as const })) - const api = scriptedApi({ subagents: { interrupt } }) - const c = client(api) - - const accepted = await c.subagents.interrupt({ - parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable', - }) - expect(accepted.result).toEqual({ ok: true, value: { accepted: true } }) - expect(interrupt).toHaveBeenCalledTimes(1) - - // The wire schema owns the mode fence: a one-shot address never reaches the impl. - const oneShot = await c.subagents.interrupt({ - parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot', - } as never) - expect(oneShot.result.ok).toBe(false) - if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request') - - const incomplete = await c.subagents.interrupt({ - parentSessionId: sid('parent'), mode: 'continuable', - } as never) - expect(incomplete.result.ok).toBe(false) - if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request') - expect(interrupt).toHaveBeenCalledTimes(1) - }) - it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 10614e8caa..b56fa89d82 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -7,31 +7,6 @@ import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' /** Minimal in-memory ApiProxy that echoes rpcIds. */ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { return { - subagents: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } } - }, - async prompt(request, signal) { - if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) { - if (!signal.aborted) { - await new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - } - return { - rpcId: request.rpcId, - result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } }, - } - } - return { - rpcId: request.rpcId, - result: { ok: true, value: { messageId: 'message-1' as never } }, - } - }, - async interrupt(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - }, host: { async describe(request) { if (overrides.crashOn === 'host.describe') throw new Error('impl crashed') @@ -221,23 +196,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } }) - it('round-trips the subagent domain through the wire form', async () => { - const c = client() - expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result) - .toEqual({ ok: true, value: { entries: [], parentAvailable: false } }) - expect((await c.subagents.prompt({ - parentSessionId: 'parent' as never, - childSessionId: 'child' as never, - mode: 'continuable', - content: [], - })).result).toEqual({ ok: true, value: { messageId: 'message-1' } }) - expect((await c.subagents.interrupt({ - parentSessionId: 'parent' as never, - childSessionId: 'child' as never, - mode: 'continuable', - })).result).toEqual({ ok: true, value: { accepted: true } }) - }) - it('keeps caller and connection aborts on a deadline-exempt unary', async () => { const api = fakeApi() const started = Promise.withResolvers() @@ -263,34 +221,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into subagent.prompt', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ - type: 'client-request', - rpcId: 'r-subagent-sig', - method: 'subagent.prompt', - payload: { - parentSessionId: 'parent', - childSessionId: 'child', - mode: 'continuable', - content: [{ type: 'text', text: 'hang' }], - }, - }) - const pending = handler.fetch(new Request( - 'http://x/api/subagent.prompt', - { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }, - )) - controller.abort() - const response = await pending - const parsed = await response.json() as { - rpcId: string - result: { error?: { code: string } } - } - expect(parsed.rpcId).toBe('r-subagent-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into host.pickDirectory', async () => { const api = fakeApi() api.host.pickDirectory = async (request, signal) => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 2644262bfa..0a803154b8 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -12,7 +12,6 @@ import { } from '../src/api/host.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts' -import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts' describe('RpcId', () => { it('brands a raw string at zero runtime cost', () => { @@ -51,12 +50,6 @@ describe('rpcErrorSchema', () => { // The credentials producer still emits this code, so the branch has to stay. expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') - expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable') - expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found') - expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic') - expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable') - expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized') - expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -99,24 +92,6 @@ describe('wire full-form schemas', () => { }) }) -describe('subagent domain schemas', () => { - it('carries optional request-local browser-zone provenance on prompts', () => { - expect(subagentPromptRequestSchema.parse({ - parentSessionId: 'parent', - childSessionId: 'child', - mode: 'continuable', - content: [{ type: 'text', text: 'continue' }], - clientTimeZone: 'Asia/Shanghai', - }).clientTimeZone).toBe('Asia/Shanghai') - expect(subagentPromptRequestSchema.parse({ - parentSessionId: 'parent', - childSessionId: 'child', - mode: 'continuable', - content: [], - }).clientTimeZone).toBeUndefined() - }) -}) - describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 87fb2a1b11..1e1d3e37df 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -53,9 +53,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../subagent/subagent" - }, { "path": "../../skill/skill" }, diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index cc41741f22..e4479686f6 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/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 packages/session/session-persistence-jsonl/README.md -README.md: cd0e9fefaed6260f890efd07160dbc264b915c9a -README.zh.md: 8be8571bc7424621d4ecb940a8e651c8b5f73e6e +README.md: 69fb2901d783c327878cd37570aec730a3ca0841 +README.zh.md: 188982028374d4ca11a5a658acabce5fe5df9930 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index cd0e9fefae..69fb2901d7 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### On-disk layout -Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run): +Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run). Storage records use the lossless provenance representation described below: ```text / @@ -90,7 +90,7 @@ The backend is a thin storage layer over the shared [PersistenceCoordinator](../ ### Physical encoding -The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). Listing reads and validates only the header frame. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically. +The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, then one checksummed frame per durable append batch, using Node's built-in Zstandard API at its default compression level (no level knob). `sourceEventSeqs` uses a lossless storage representation: consecutive runs of at least three sequence numbers become `[start, end]` pairs, any other list stays verbatim, and reading expands the exact in-memory array. Listing reads and validates only the header frame. `compression: 'none'` keeps the same storage-form logical lines without frame compression. A root belongs to one encoding: startup discovery and targeted lookup reject the opposite suffix, and there is no format or compression migration, mixed-root fallback, or dual write. When `packChunks` is enabled, an eligible run of ≥3 consecutive same-block `assistant/chunk` delta events becomes one packed row (`text-chunks`/`reasoning-chunks`/`tool-call-chunks`) whose `seq0`/`time0` and per-member `dt` gaps reconstruct every member exactly; the lossless codec lives in `dsh-session` and reading is layout-blind, so packed, unpacked, and mixed files load identically. ### Source map diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 8be8571bc7..1889820283 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 磁盘布局 -每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行): +每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。存储记录使用下文所述的无损来源序列表示: ```text / @@ -90,7 +90,7 @@ kind: "package-reference" ### 物理编码 -默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。列表只读取并验证 header 帧。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。 +默认产物是独立 [Zstandard 帧](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带校验和帧,后跟每个持久 append 批次一个带校验和帧,使用 Node 内置 Zstandard API 的默认压缩级别(无级别开关)。`sourceEventSeqs` 使用无损存储形式:至少包含三个序列号的连续段会变成 `[start, end]` 区间对,其他列表原样保留;读取时会展开回精确的内存数组。列表只读取并验证 header 帧。`compression: 'none'` 保留相同的存储形式逻辑行,但不使用帧压缩。一个根只属于一种编码:启动发现与定向查找会拒绝相反后缀,且不提供格式或压缩迁移、混合根回退或双写。启用 `packChunks` 时,符合条件的 ≥3 个连续同 block `assistant/chunk` delta 事件连续段会变成一行打包行(`text-chunks`/`reasoning-chunks`/`tool-call-chunks`),其 `seq0`/`time0` 与各成员的 `dt` 间隔精确重建每个成员;无损 codec 位于 `dsh-session`,读取与布局无关,因此打包、非打包与混合文件加载结果一致。 ### 源码地图 diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 8092991eef..9f1ca0d4f4 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -9,7 +9,9 @@ */ import { join } from 'node:path' -import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { + decodeSeqRanges, decodeStorageRecord, encodeSeqRanges, packChunkRuns, SESSION_FORMAT_VERSION, +} from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' @@ -211,16 +213,47 @@ export function logPath( * Serialize an event batch as JSONL lines (no trailing newline). With * `packChunks` on, delta-chunk runs pack into `text-chunks` / * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event - * per line, byte-identical to the pre-packing layout. Reading is layout-blind - * either way ({@link scanLog} always decodes rows), so the switch changes only - * newly written bytes. + * per line. Both modes range-encode provenance at the storage boundary. + * Reading is layout-blind either way ({@link scanLog} always decodes rows), + * so the switch changes only newly written bytes. * @param events - the batch to serialize, in log order. * @param packChunks - whether to pack delta runs into storage rows. * @returns the batch's JSONL text; the writer adds the final newline. */ export function eventLines(events: readonly SessionEvent[], packChunks: boolean): string { const records: readonly StorageRecord[] = packChunks ? packChunkRuns(events) : events - return records.map(record => JSON.stringify(record)).join('\n') + return records.map(record => JSON.stringify(encodeProvenanceForStorage(record))).join('\n') +} + +/** + * Losslessly shrink a record's `sourceEventSeqs` for the log: consecutive + * runs of at least three seqs become `[start, end]` pairs, and any other list + * stays verbatim. + * @param record - one stored record (event or packed row). + * @returns the record with its provenance in storage form (widened from the + * in-memory `number[]`; {@link expandProvenanceFromStorage} restores it). + */ +function encodeProvenanceForStorage(record: StorageRecord): unknown { + if (!('sourceEventSeqs' in record)) return record + return { ...record, sourceEventSeqs: encodeSeqRanges(record.sourceEventSeqs) } +} + +/** + * Expand a parsed line's storage-form provenance back to `number[]`. + * @param parsed - the JSON-parsed value of one stored line. + * @returns the value with provenance expanded. + * @throws when the record or its storage-form provenance is malformed. + */ +function expandProvenanceFromStorage(parsed: unknown): unknown { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new TypeError('stored session records must be objects') + } + const record = parsed as { seq?: unknown; sourceEventSeqs?: unknown } + if (record.sourceEventSeqs === undefined) return parsed + if (!Number.isSafeInteger(record.seq) || (record.seq as number) < 0) { + throw new TypeError('stored session event seq must be a non-negative safe integer') + } + return { ...record, sourceEventSeqs: decodeSeqRanges(record.sourceEventSeqs, record.seq as number) } } interface SessionLogScan { @@ -348,7 +381,7 @@ export class SessionLogScanner { this.eventLine += 1 let decoded: SessionEvent[] try { - decoded = decodeStorageRecord(JSON.parse(line.toString('utf8'))) + decoded = decodeStorageRecord(expandProvenanceFromStorage(JSON.parse(line.toString('utf8')))) } catch { this.issue ??= new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`) return diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index eea36d5089..e244d3af51 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -992,13 +992,20 @@ describe('JsonlSessionPersistence: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/) }) - it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => { - const log = [ - JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }), - '{not json', // corrupt, sits in the committed region (a turn/end follows) - JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), - ].join('\n') + '\n' - expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/) + it('rejects malformed records before a later committed turn/end', () => { + const corruptRecords = [ + '{not json', + 'null', + JSON.stringify({ type: 'assistant/message', sourceEventSeqs: [0], data: {} }), + ] + for (const record of corruptRecords) { + const log = [ + JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }), + record, + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), + ].join('\n') + '\n' + expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/) + } }) it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => { @@ -1177,9 +1184,20 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8')) }) - it('eventLines(packChunks: false) is byte-identical to the pre-packing layout', () => { + it('eventLines(packChunks: false) keeps one event per line and round-trips provenance', () => { const log = chunkRunLog() - expect(eventLines(log, false)).toBe(log.map(e => JSON.stringify(e)).join('\n')) + const text = eventLines(log, false) + const lines = text.split('\n') + expect(lines).toHaveLength(log.length) + for (const line of lines) { + expect((JSON.parse(line) as { type: string }).type).not.toMatch(/-chunks$/) + } + // the message's consecutive provenance is stored as an inclusive range + const messageLine = lines.map(l => JSON.parse(l) as { type: string; sourceEventSeqs?: unknown }) + .find(r => r.type === 'assistant/message') + expect(messageLine?.sourceEventSeqs).toEqual([[2, 6]]) + const header = JSON.stringify(toHeaderLine(meta('packed', '/work'))) + '\n' + expect(scanLog(Buffer.from(header + text + '\n')).events).toEqual(log) }) }) diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml index 53ccd335fb..22923e8806 100644 --- a/packages/session/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session/session-persistence-sqlite/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/session-persistence-sqlite/README.md -README.md: 5f8d4cebf1a189cd768673359686ab2774550285 -README.zh.md: 12900cc8783430b8716612cdda55d2622cc7f7aa +README.md: fc8e8eb7032eda475fceb065b80315421020a63f +README.zh.md: 88858ea07ff6bee54d29b8af947e610dc3879901 diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md index 5f8d4cebf1..fc8e8eb703 100644 --- a/packages/session/session-persistence-sqlite/README.md +++ b/packages/session/session-persistence-sqlite/README.md @@ -33,9 +33,7 @@ Choose this backend when a local deployment benefits from one queryable database ### Disk footprint and performance -The packed layout trades disk space for speed and structure. The available benchmark measures schema 17, the packed predecessor with the same chunk codec but the former row discriminator; schema 18 has not been remeasured. On its corpus — 105 sessions, about 2.5 million events — the SQLite database used 75 MB against 31 MB for the default compressed JSONL logs: roughly 2.5× the on-disk size. - -The same measurements show writes finishing about 3× faster, 50-event suffix reads about 40× faster, full-session reads comparable or slightly faster, and about 2.5 million physical rows shrinking to roughly 66 thousand. Expect 2–3× the compressed JSONL footprint depending on session content; the full numbers and method live in the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md). +The packed layout exchanges some SQLite-local latency for a smaller queryable database. On the 501-session comparison corpus, the schema-19 layout used 233.18 MB against the SQLite comparison baseline's 438.31 MB and compressed JSONL's 148.15 MB. Full writes were about 2.3× faster than JSONL and suffix reads remained much faster; complete reads and forks were slightly slower than JSONL. The [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) owns the method, complete metrics, and accepted trade-offs. The disk cost buys a structured, queryable view of session history: external tooling can analyze `sessions` and `events` with SQL, decoding physical rows the way this provider does — the groundwork for features such as built-in full-text search. @@ -77,7 +75,7 @@ await ctx.sessionPersistence.append(id, events) ### Startup and safe operation -A fresh database initializes directly at schema version 18. Databases with any other version, a foreign application identity, an unversioned non-pristine schema, or unexpected schema objects are rejected before any data is exposed or changed — this pre-release provider ships no migration. Every statement and fixed pragma comes from packaged `.sql` resources in `resources/sql/`, and runtime values are bound as SQLite parameters, so package code never assembles query text. +A fresh database initializes directly at schema version 19 with 64 KiB pages. Existing files are never retuned: databases with any other version, a foreign application identity, an unversioned non-pristine schema, or unexpected schema objects are rejected before any data is exposed or changed. This pre-release provider ships no migration. Every statement and fixed pragma comes from packaged `.sql` resources in `resources/sql/`, and runtime values are bound as SQLite parameters, so package code never assembles query text. Each connection disables SQLite trusted schemas and memory-mapped I/O, verifies the requested journal mode, and pins `synchronous=FULL` so a resolved append remains durable across an OS crash or power loss. On POSIX, the database parent directory and file must belong to the current user, the parent must not be group/world-writable, and the file must grant no group or world permissions; Windows additionally rejects symbolic links and non-regular files, while ACL restriction stays the deployment's job. Path and ownership failures reject plugin initialization; Node's SQLite driver loads lazily on the first persistence operation. Ordinary `create` stays lazy until the first append, while `ensureMaterialized` writes a session metadata row with no event rows. @@ -96,11 +94,11 @@ This section explains the design decisions behind the provider and points at the The provider is built on one separation and three commitments: - **Logical contract, physical format.** Callers always read and write ordinary `SessionEvent[]`; how rows are packed, stored, and compressed is private to this package. -- **The schema owns the format.** Schema 18 is a frozen physical contract: a database at another version, with a foreign identity, or with unexpected schema objects is rejected, never migrated. Changing the physical rules requires a new schema. +- **The schema owns the format.** Schema 19 is a frozen physical contract: a database at another version, with a foreign identity, or with unexpected schema objects is rejected, never migrated. Changing the schema, row codec, page size, or dictionary bytes requires a new schema version. - **Durability is the default.** Appends run in immediate transactions with `synchronous=FULL`, and a resolved `append()` means the batch is durable. Normal appends are insert-only: earlier event rows are never rewritten. - **Efficiency within strict bounds.** Packing and compression keep the database small, but every limit is a hard format bound — at most 1,024 events and 1 MiB of payload per packed row. -The decision history — alternatives considered, measurements, and consequences — lives in the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md). +The packed-row foundation lives in the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md); the current compression, key, and page-size choices live in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md). ### Source map @@ -110,7 +108,7 @@ The decision history — alternatives considered, measurements, and consequences | [`src/store.ts`](src/store.ts) | Storage primitives: transactional append, reads, repair, path and ownership validation | | [`src/schema.ts`](src/schema.ts) | Schema ownership: version gate, connection hardening, row decoding | | [`src/codec.ts`](src/codec.ts) | Packing: which `assistant/chunk` runs become packed rows, size bounds | -| [`src/compression.ts`](src/compression.ts) | Physical encoding: compression threshold, sequence lists, row scan and decode | +| [`src/compression.ts`](src/compression.ts) | Physical encoding: dictionary compression, sequence lists, row scan and decode | | [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | Every SQL statement as a packaged, closed-name resource | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; packing is observable only by database round-trip) | @@ -124,7 +122,7 @@ A fresh database contains three strict tables, defined in [`resources/sql/schema | `sessions` | One row per session: header fields plus a monotonic revision | | `events` | Physical event rows: one logical event, or one packed run | -The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `events.data` holds text or a blob: small payloads stay text, larger ones are stored compressed when that is smaller. `events.is_packed` is `0` for a scalar logical event and `1` for a packed chunk run, so a scalar event whose type matches a physical chunk tag remains unambiguous. Packed rows reuse the `seq` of their first logical event, so under the composite `(session_id, seq)` primary key physical order is logical order. +The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `sessions.id` is an internal integer key while `sessions.session_key` retains the public session id. `events.data` holds text or an independently decodable Zstandard blob; compression uses the schema-owned shared dictionary only when the result is smaller. `events.source_event_seqs` uses tagged delta or run encoding. `events.is_packed` is `0` for a scalar logical event and `1` for a packed chunk run, so a scalar event whose type matches a physical chunk tag remains unambiguous. Packed rows reuse the `seq` of their first logical event, so under the composite `(session_id, seq)` primary key physical order is logical order. ### Write path @@ -147,6 +145,7 @@ Read these pages when the package-level contract is not enough. They move from t - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-sqlite) — every accepted config field and its source declaration. - [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) — rationale, alternatives, and measurements behind the packed layout. +- [Persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) — the 501-session benchmark and schema-19 storage trade-offs. ----- @@ -174,9 +173,9 @@ Physical packing does not mutate request prefixes. Provider cache reuse depends These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general SQLite comparison or a task backlog. -- **Pre-release design with no migration** — schema 18 is an interim SQLite-only design; the deferred unified multi-backend relational design with configurable schemas exists as a working external prototype in [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb) (Drizzle-based, SQLite and PostgreSQL), and neither schema stability nor migration support is guaranteed. +- **Pre-release design with no migration** — schema 19 is an interim SQLite-only design; neither schema stability nor migration support is guaranteed. - **Packing depends on batch boundaries** — a compatible run split by the write-behind window or an explicit flush stays split across physical rows; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio. -- **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread; the 4 KiB compression threshold bounds per-frame work for small records. +- **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread. - **Busy waits block the event loop** — SQLite waits inside synchronous calls; a competing writer can stall the thread for up to the configured `busyTimeoutMs`. - **External SQL readers must decode physical rows** — a packed `events.type` (`text-chunks`, `reasoning-chunks`, `tool-call-chunks`) is not a logical event type; supported consumers read through this provider. - **No deletion or historical compaction** — normal appends are insert-only and nothing removes old rows. @@ -187,37 +186,6 @@ These limits define when the provider is a poor fit or needs special operational

Working context for maintainers — click to expand -This Dev Note is working context for maintainers: measured artifacts, open design questions, and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes, and conclusions migrate there once they stabilize. - -#### Benchmark artifact - -The numbers below are the frozen schema-17 benchmark. Schema 18 changes the row discriminator and has not been remeasured; the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) is the authoritative record, and this table is an annotated digest. - -| Metric | **JSONL (zstd)** | **SQLite (legacy)** | **SQLite (new)** | -|---|---|---|---| -| On-disk size | **30.65 MB** | 709.57 MB | 75.01 MB | -| Write time, 105 sessions | 28.21 s | 10.64 s | **8.58 s** | -| Complete-session read p50 / p95 | 4.49 / 23.36 ms | 9.02 / 69.16 ms | **3.95 / 21.58 ms** | -| 50-event tail read p50 / p95 | 10.58 / 80.90 ms | **0.189 / 0.293 ms** | 0.253 / 0.378 ms | -| Event rows | 2,507,860 (logical) | 2,507,860 | **65,810** | -| Fork of all sessions | 14.48 s | 19.30 s | **13.10 s** | - -The corpus was 105 sessions with 2,507,860 logical events appended in 512-event durable batches, so the ratios depend on session content, stream density, and batch boundaries. `SQLite (legacy)` is the scalar layout — one physical row per logical event, no packing — whose 709.57 MB footprint motivated the packed rows. In the measured schema-17 layout, SQLite uses ≈2.5× the JSONL disk space but writes ≈3.3× faster, reads complete sessions faster at both percentiles, and reads 50-event tails ≈40× faster; against the scalar layout it is ≈89% smaller, faster to write, and shrinks 2,507,860 rows to 65,810, while scalar tail reads remain marginally faster (0.189 vs 0.253 ms p50). Re-run or extend this benchmark whenever the write path or the schema changes. - -#### Future: multi-backend RDB persistence (Drizzle) - -A unified multi-backend relational design stays deferred. A Drizzle-backed rework would need to resolve: schema ownership — the per-version freeze and exact-object validation exist so any composition can read any same-version database, so a customizable schema must remain versioned and validated the same way; backend hardening — `synchronous=FULL`, busy timeout, and ownership checks are SQLite-specific, and Postgres or MySQL backends need their own durability and permission story; and codec portability — the packed-row codec is shaped around SQLite columns, so either a shared codec across dialects or per-backend codecs fixed by schema version must keep the logical contract identical. - -#### Future: persistence-to-persistence transfer and version migration - -The README documents a manual `load` → `create`/`append` transfer, but the seam has no import/export API, and SQLite rejects other schema versions outright. Automating transfer needs: an export format that preserves header lineage (`seedLength`, `parentSession`, `agentPreset`) and revision semantics; an upgrader chain for format and schema versions, the deferred chain from the [fail-closed event-vocabulary note](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md); and a source-side guarantee that the log is readable and balanced before export — `load` already commits cold repair. - -#### Future: in-database full-text search and indexing - -The sibling [session-query-sqlite](../../session-query/session-query-sqlite/README.md) package already maintains a dedicated SQLite FTS5 search index over session content in a separate derived-index database. Putting FTS inside the persistence database would duplicate that surface; open questions are where the index belongs, how to keep it transactional with append, and whether packed rows should be expanded into index documents or the index should read the logical stream. The persistence schema currently indexes only `(session_id, seq)`; further indexes (for example on `sessions.created_at` for cold-cutoff scans) are easy but add write cost. - -#### Future: cold-data offloading to cascaded database files - -The provider has no deletion or background compaction: everything stays in one database forever. One direction is offloading cold sessions (for example, older than 30 days) into separate archive database files arranged as a cascade, with more aggressive compression — a higher Zstandard level is cheap for cold data. That needs: a routing rule that knows which file holds which session, cross-file fan-out for `list`/`readFrom`/`load`, consistent revisions and store identity across the cascade, and a decision on whether offloading replaces the no-deletion limit or supplements it. +The 501-session corpus contains private session data and is not committed. Its aggregate method, complete results, and rejected candidates are recorded in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md); the packaged dictionary's hash-pinned resource is the schema-19 source of truth.
diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md index 12900cc878..88858ea07f 100644 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ b/packages/session/session-persistence-sqlite/README.zh.md @@ -33,9 +33,7 @@ kind: "package-reference" ### 磁盘占用与性能 -打包布局以磁盘空间换取速度与结构。现有基准测量 schema 17,该打包前身使用相同的分片 codec,但行判别值不同;schema 18 尚未重新测量。在该语料上——105 个会话、约 250 万个事件——SQLite 数据库占用 75 MB,而默认压缩 JSONL 日志为 31 MB:磁盘占用约为后者的 2.5 倍。 - -同一组测量显示,写入快约 3 倍,50 个事件的后缀读取快约 40 倍,完整会话读取相当或略快,约 250 万个物理行缩减到约 6.6 万个。按会话内容不同,磁盘占用约为压缩 JSONL 的 2–3 倍;完整数据与方法见 [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)。 +打包布局以部分 SQLite 本地延迟换取更小的可查询数据库。在 501 会话对比语料上,schema-19 布局占用 233.18 MB,SQLite 对比基线占用 438.31 MB,压缩 JSONL 占用 148.15 MB。全量写入约比 JSONL 快 2.3 倍,后缀读取也仍快得多;完整读取与 fork 则略慢于 JSONL。方法、完整指标与取舍由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。 磁盘成本换来的是结构化、可查询的会话历史视图:外部工具可以用 SQL 分析 `sessions` 与 `events`,按本提供方的方式解码物理行——这是内置全文搜索等功能的天然基础。 @@ -77,7 +75,7 @@ await ctx.sessionPersistence.append(id, events) ### 启动与安全运行 -全新数据库直接初始化为 schema 版本 18。任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝——本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。 +全新数据库直接初始化为 schema 版本 19,并使用 64 KiB page。已有文件不会被重新调参:任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝。本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。 每个连接都会禁用 SQLite trusted schema 与内存映射 I/O、验证所请求的 journal mode,并固定 `synchronous=FULL`,保证成功返回的追加在操作系统崩溃或断电后依然持久。在 POSIX 上,数据库父目录和文件必须属于当前用户,父目录不得允许组或其他用户写入,文件也不得授予任何组或其他用户权限;Windows 还会拒绝符号链接和非普通文件,ACL 限制则由部署方负责。路径与所有权失败会拒绝插件初始化;Node 的 SQLite 驱动在首次持久化操作时才延迟加载。普通 `create` 会保持惰性直到首次 append,而 `ensureMaterialized` 会写入一条没有事件行的会话元数据记录。 @@ -96,11 +94,11 @@ await ctx.sessionPersistence.append(id, events) 本提供方建立在一个分离与三项承诺之上: - **逻辑约定,物理格式。** 调用方始终读写普通的 `SessionEvent[]`;行如何打包、存储与压缩是本包私有的存储行为。 -- **schema 拥有格式。** Schema 18 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变物理规则需要新的 schema。 +- **schema 拥有格式。** Schema 19 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变 schema、行 codec、page size 或字典字节都需要新的 schema 版本。 - **持久性是默认值。** 追加在立即事务中以 `synchronous=FULL` 提交,成功返回的 `append()` 意味着该批次已持久。普通追加仅插入:更早的事件行永远不会被重写。 - **在严格边界内追求效率。** 打包与压缩让数据库保持小巧,但每个上限都是硬性格式边界——每个打包行至多表示 1,024 个事件、1 MiB 载荷。 -决策历史——备选方案、测量数据与后果——记录在 [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md) 中。 +打包行基础由 [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)记录;当前压缩、键和 page-size 选择由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。 ### 源码地图 @@ -110,7 +108,7 @@ await ctx.sessionPersistence.append(id, events) | [`src/store.ts`](src/store.ts) | 存储原语:事务追加、读取、修复、路径与所有权验证 | | [`src/schema.ts`](src/schema.ts) | schema 归属:版本门禁、连接加固、行解码 | | [`src/codec.ts`](src/codec.ts) | 打包:哪些 `assistant/chunk` 连续段成为打包行、大小上限 | -| [`src/compression.ts`](src/compression.ts) | 物理编码:压缩阈值、序列列表、行扫描与解码 | +| [`src/compression.ts`](src/compression.ts) | 物理编码:字典压缩、序列列表、行扫描与解码 | | [`src/sql.ts`](src/sql.ts) + [`resources/sql/`](resources/sql/) | 所有 SQL 语句均为打包的闭名资源 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;打包只能通过数据库往返观察) | @@ -124,7 +122,7 @@ await ctx.sessionPersistence.append(id, events) | `sessions` | 每个会话一行:头部字段加单调递增的 revision | | `events` | 物理事件行:一个逻辑事件,或一个打包连续段 | -确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`events.data` 列存放文本或 blob:小载荷保持为文本,较大的载荷在压缩后更小时以压缩形式存储。标量逻辑事件的 `events.is_packed` 为 `0`,打包分片连续段的该值为 `1`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。 +确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`sessions.id` 是内部整数键,`sessions.session_key` 保留公开会话 id。`events.data` 存放文本或可独立解码的 Zstandard blob;仅在结果更小时才使用 schema 自有的共享字典压缩。`events.source_event_seqs` 使用带 tag 的 delta 或 run 编码。标量逻辑事件的 `events.is_packed` 为 `0`,打包分片连续段的该值为 `1`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。 ### 写入路径 @@ -147,6 +145,7 @@ await ctx.sessionPersistence.append(id, events) - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-sqlite)——每个受支持配置字段及其源声明。 - [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)——打包布局背后的理由、备选方案与测量。 +- [持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)——501 会话基准与 schema-19 存储取舍。 ----- @@ -174,9 +173,9 @@ await ctx.sessionPersistence.append(id, events) 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 SQLite 对比或任务积压。 -- **预发布设计,无迁移**——schema 18 是临时的 SQLite 专用设计;被推迟的统一多后端、可配置 schema 关系型设计已有可运行的外部原型 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)(基于 Drizzle,支持 SQLite 与 PostgreSQL),预发布期间不保证 schema 稳定性或迁移支持。 +- **预发布设计,无迁移**——schema 19 是临时的 SQLite 专用设计;不保证 schema 稳定性或迁移支持。 - **打包依赖批次边界**——被写后窗口或显式 flush 拆开的兼容连续段仍分属不同物理行;这避免了重写先前行,代价是打包比例依赖时序。 -- **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程;4 KiB 压缩阈值限制了小记录的单帧工作量。 +- **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程。 - **忙等待阻塞事件循环**——SQLite 在同步调用内部等待;竞争写入方最长可让线程停顿配置的 `busyTimeoutMs`。 - **外部 SQL 读取方必须解码物理行**——打包的 `events.type`(`text-chunks`、`reasoning-chunks`、`tool-call-chunks`)不是逻辑事件类型;受支持的消费方通过本提供方读取。 - **没有删除或历史压缩**——普通追加仅插入,没有任何机制移除旧行。 @@ -187,37 +186,6 @@ await ctx.sessionPersistence.append(id, events)
维护者的工作上下文——点击展开 -本开发备注是维护者的工作上下文:测量产物、开放设计问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关 Agent Note 为准,结论一旦稳定就迁移到对应归属。 - -#### 基准产物 - -以下数字是冻结的 schema 17 基准。Schema 18 改变了行判别值,尚未重新测量;[SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md) 是权威记录,本表只是带注的摘要。 - -| 指标 | **JSONL(zstd)** | **SQLite(legacy)** | **SQLite(new)** | -|---|---|---|---| -| 磁盘占用 | **30.65 MB** | 709.57 MB | 75.01 MB | -| 105 个会话的写入时间 | 28.21 s | 10.64 s | **8.58 s** | -| 完整会话读取 p50 / p95 | 4.49 / 23.36 ms | 9.02 / 69.16 ms | **3.95 / 21.58 ms** | -| 50 个事件尾部读取 p50 / p95 | 10.58 / 80.90 ms | **0.189 / 0.293 ms** | 0.253 / 0.378 ms | -| 事件行数 | 2,507,860(逻辑) | 2,507,860 | **65,810** | -| 全部会话 fork | 14.48 s | 19.30 s | **13.10 s** | - -语料为 105 个会话、2,507,860 个逻辑事件,按 512 个事件一批追加,因此具体比例取决于会话内容、流密度与批次边界。`SQLite(legacy)` 是标量布局——每个逻辑事件一行、不打包——其 709.57 MB 的占用正是打包行的动机。在已测量的 schema 17 布局中,SQLite 磁盘占用约为 JSONL 的 2.5 倍,但写入快约 3.3 倍,完整会话读取在两个分位上都更快,50 个事件尾部读取快约 40 倍;相对标量布局,它缩小约 89%、写入更快,并把 2,507,860 行缩减到 65,810 行,只有标量尾部读取仍略快(0.189 对 0.253 ms p50)。写入路径或 schema 变化时,请重跑或扩展该基准。 - -#### 未来:多后端 RDB 持久化(Drizzle) - -统一的多后端关系型设计仍被推迟。若以 Drizzle 实现,需要解决:schema 归属——逐版本冻结与精确对象校验的意义在于任何组合都能读取同版本数据库,可定制 schema 必须同样带版本并接受同样校验;后端加固——`synchronous=FULL`、busy timeout 与所有权检查都是 SQLite 专属,Postgres 或 MySQL 后端需要各自的持久性与权限方案;codec 可移植性——打包 codec 围绕 SQLite 列设计,无论跨方言共享 codec 还是按 schema 版本固定每后端 codec,都必须保持逻辑约定完全一致。 - -#### 未来:持久化到持久化的迁移与版本升级 - -README 记录了手动的 `load` → `create`/`append` 迁移,但 seam 没有导入/导出 API,SQLite 也直接拒绝其他 schema 版本。自动化迁移需要:能保留头部血缘(`seedLength`、`parentSession`、`agentPreset`)与 revision 语义的导出格式;格式与 schema 版本的升级链,即[事件词汇表显式拒绝笔记](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md)中推迟的升级链;以及导出前源日志可读且平衡的保证——`load` 已先提交冷修复。 - -#### 未来:库内全文搜索与索引改进 - -兄弟包 [session-query-sqlite](../../session-query/session-query-sqlite/README.zh.md) 已经在独立派生索引数据库中维护一份会话内容的 SQLite FTS5 搜索索引。把 FTS 放进持久化数据库会重复该表面;开放问题包括索引归属、如何与追加保持事务一致,以及打包行应展开成索引文档还是索引直接读取逻辑流。持久化 schema 目前只索引 `(session_id, seq)`;额外索引(例如 `sessions.created_at` 用于冷数据截止扫描)实现简单,但会增加写入成本。 - -#### 未来:冷数据卸载到级联数据库文件 - -本提供方没有删除或后台压缩:所有数据永远留在同一个数据库里。一个方向是把冷会话(例如超过 30 天)卸载到按级联组织的独立归档数据库文件,并对冷文件使用更激进的压缩——对冷数据而言,更高的 Zstandard 级别代价很低。这需要:知道哪个文件存放哪个会话的路由规则、`list`/`readFrom`/`load` 的跨文件扇出、级联间一致的 revision 与存储标识,以及卸载是取代还是补充「无删除」限制的决定。 +501 会话语料包含私有会话数据,因此不提交到仓库。汇总方法、完整结果与未采用候选记录在[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)中;schema 19 以打包资源及测试固定的字典摘要为准。
diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index a4495d43a9..01563c3738 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -28,6 +28,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "resources/zstd-dictionary.bin", "resources/sql/**/*.sql", "lib/types/**/*.d.ts" ], diff --git a/packages/session/session-persistence-sqlite/resources/sql/page-size.sql b/packages/session/session-persistence-sqlite/resources/sql/page-size.sql new file mode 100644 index 0000000000..3d5845fd61 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/page-size.sql @@ -0,0 +1 @@ +PRAGMA page_size = 65536; diff --git a/packages/session/session-persistence-sqlite/resources/sql/schema.sql b/packages/session/session-persistence-sqlite/resources/sql/schema.sql index e5c8443c3c..d8b24d9606 100644 --- a/packages/session/session-persistence-sqlite/resources/sql/schema.sql +++ b/packages/session/session-persistence-sqlite/resources/sql/schema.sql @@ -4,7 +4,8 @@ CREATE TABLE persistence_state ( ) STRICT; CREATE TABLE sessions ( - id TEXT PRIMARY KEY, + id INTEGER PRIMARY KEY, + session_key TEXT NOT NULL UNIQUE, version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, @@ -18,7 +19,7 @@ CREATE TABLE sessions ( ) STRICT; CREATE TABLE events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, seq INTEGER NOT NULL, type TEXT NOT NULL, time INTEGER NOT NULL, diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql new file mode 100644 index 0000000000..a0d3a4688a --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/select-session-key.sql @@ -0,0 +1,3 @@ +SELECT id +FROM sessions +WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql index 456cba91c3..fdd2b1a6da 100644 --- a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql +++ b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql @@ -1,4 +1,4 @@ -SELECT id, version, created_at, cwd, parent_session, seed_length, origin, +SELECT session_key AS id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision FROM sessions -WHERE id = ?; +WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql index 37a1f22e36..52d0a4b61f 100644 --- a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql +++ b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql @@ -1,3 +1,3 @@ -SELECT id, version, created_at, cwd, parent_session, seed_length, origin, +SELECT session_key AS id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision FROM sessions; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-18.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-18.sql deleted file mode 100644 index 54cfecd52c..0000000000 --- a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-18.sql +++ /dev/null @@ -1 +0,0 @@ -PRAGMA user_version = 18; diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-19.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-19.sql new file mode 100644 index 0000000000..c1082b72f4 --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-19.sql @@ -0,0 +1 @@ +PRAGMA user_version = 19; diff --git a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql index 2cfbcb2b82..3af6c1c23f 100644 --- a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql +++ b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql @@ -1,3 +1,3 @@ UPDATE sessions SET revision = revision + 1 -WHERE id = ?; +WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql index c1000e4daa..45b4a088f2 100644 --- a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql +++ b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql @@ -1,8 +1,8 @@ INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, + (session_key, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) -ON CONFLICT(id) DO UPDATE SET +ON CONFLICT(session_key) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, @@ -10,4 +10,5 @@ ON CONFLICT(id) DO UPDATE SET seed_length = excluded.seed_length, origin = excluded.origin, delegation_depth = excluded.delegation_depth, - agent_preset = excluded.agent_preset; + agent_preset = excluded.agent_preset +RETURNING id; diff --git a/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin b/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin new file mode 100644 index 0000000000..97fc36feaa --- /dev/null +++ b/packages/session/session-persistence-sqlite/resources/zstd-dictionary.bin @@ -0,0 +1,420 @@ +{"turn":1} +{"policy":"never"} +{"turn":1,"step":2} +{"turn":1,"step":4} +{"turn":1,"step":6} +{"mode":"read-only"} +{"mode":"workspace-write"} +{"mode":"danger-full-access"} +{"compactionId":"{{id:1}}","turn":1} +{"policy":"never","source":"delegation"} +{"turn":1,"reason":{"kind":"max-tokens"}} +{"runId":"{{workflow:1}}","name":"snapshot-flow"} +{"mode":"danger-full-access","source":"delegation"} +{"retryId":"{{retry:1}}","turn":1,"step":1,"retry":1} +{"runId":"{{workflow:1}}","name":"advanced-acp-snapshot"} +{"todos":[{"content":"keep going","status":"in_progress"}]} +{"runId":"{{workflow:1}}","name":"advanced-headless-snapshot"} +{"target":"next-turn","start":0,"removedCount":1,"inserted":[]} +{"turn":1,"step":2,"index":1,"dt":[0,0],"texts":["B","OTH","_OK"]} +{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]} +{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"} +{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}} +{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}} +{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}} +{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}} +{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}} +{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"} +{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}} +{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}} +{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}} +{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}} +{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}} +{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE."}} +{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}} +{"turn":1,"step":3,"index":1,"dt":[0,0,1],"texts":["PAR","ENT","_D","ONE"]} +{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}} +{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}} +{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}} +{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth one"} +{"version":3,"mode":"one-shot","provider":"spawn","label":"Truncated child"} +{"title":"Do NOT use the read","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ROOT_DONE"}} +{"title":"Use the bash tool to","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}} +{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}} +{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}} +{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}} +{"turn":1,"step":5,"callId":"pty-list","name":"terminal_list","arguments":"{}"} +{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}} +{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"} +{"title":"Call the bash tool to","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use the read tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Run true once with bash","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use the write tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"point":"Stop","dialect":"claude-code","handlerId":"claude-code:Stop:2"} +{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000} +{"title":"You are one fresh worker","messageSeqs":[8],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}} +{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"} +{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}} +{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}} +{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}} +{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}} +{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} +{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} +{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}} +{"title":"Exercise the six PTY tools","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Reply with the single word","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"What is my favorite color?","messageSeqs":[9],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"PARENT_COMPLETED"}} +{"title":"Call the run_code tool (NOT","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use the subagent tool TWICE","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0],"texts":["G","LOB","_S","AM","PL","ED"]} +{"title":"A file named greeting.txt in","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Perform these exact steps in","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use read_image on red.png in","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} +{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} +{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}} +{"title":"This prompt first receives an","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use the subagent tool exactly","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}} +{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} +{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} +{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}} +{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} +{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}} +{"title":"Run this advanced flow exactly","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Use the web_fetch tool exactly","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_EXIT_PRESERVED"}} +{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}} +{"title":"This prompt triggers a recorded","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}} +{"title":"Observe the ACP diagnostic twice","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Using ONE run_code program: call","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}} +{"title":"Call ask_user_question once to ask","messageSeqs":[8],"source":{"kind":"fallback"}} +{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"RUNNER_FAILURES_SURFACED"}} +{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}} +{"title":"Delegate one foreground subagent. Its","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WIDE"}}} +{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0],"texts":["CODE","_","ONE","+","CODE","_T","WO"]} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}} +{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}} +{"title":"Establish a durable compaction premise","messageSeqs":[7],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}} +{"title":"Delegate through two child generations.","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Load the editing-cordis-compositions ski","messageSeqs":[7],"source":{"kind":"fallback"}} +{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}} +{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}} +{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}} +{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"} +{"turn":1,"step":1,"callId":"fs-edit-read","name":"read","arguments":"{\"file_path\":\"config.txt\"}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}} +{"turn":1,"step":4,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}} +{"turn":1,"reason":{"kind":"error","error":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_COMPLETED"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}} +{"turn":1,"point":"PreToolUse","dialect":"claude-code","handlerId":"claude-code:PreToolUse:1","matcher":"bash"} +{"turn":1,"step":3,"callId":"fs-delete-read-after","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}} +{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}} +{"turn":1,"point":"PostToolUse","dialect":"claude-code","handlerId":"claude-code:PostToolUse:2","matcher":"bash"} +{"turn":1,"step":4,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"} +{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"} +{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":2.7725000000000364} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}} +{"turn":1,"point":"Stop","handlerId":"claude-code:Stop:2","decision":"pass","exitCode":0,"durationMs":2.744416000000001} +{"turn":1,"step":1,"callId":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"} +{"turn":1,"step":3,"callId":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"} +{"turn":1,"step":2,"callId":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"} +{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"} +{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}} +{"turn":1,"step":2,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}} +{"turn":1,"point":"PreToolUse","handlerId":"claude-code:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":4.231499999999869} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2880,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}} +{"turn":1,"step":2,"callId":"fs-overwrite-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}} +{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}} +{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":50,"cacheReadTokens":6272,"reasoningTokens":42}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":170,"outputTokens":28,"cacheReadTokens":2816,"reasoningTokens":25}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":41,"cacheReadTokens":2816,"reasoningTokens":27}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":51,"cacheReadTokens":2816,"reasoningTokens":30}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":292,"outputTokens":30,"cacheReadTokens":2816,"reasoningTokens":27}}} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":35,"cacheReadTokens":10496,"reasoningTokens":31}}} +{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}} +{"runId":"{{workflow:1}}","seq":1,"label":"Reply with exactly the word WF_CHILD_OK and not…","phase":"Run","childId":"{{session:2}}"} +{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}}} +{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":4.196374999999989} +{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"} +{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"} +{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}} +{"turn":1,"point":"UserPromptSubmit","handlerId":"claude-code:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":4.07300000000032} +{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}} +{"turn":1,"step":1,"callId":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"} +{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"turn":1,"step":3,"callId":"bounded-task-kill","name":"job_kill","arguments":"{\"job_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-edit-read","name":"read","argumentsDelta":"{\"file_path\":\"config.txt\"}"}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-bounded-read","name":"read","argumentsDelta":"{\"file_path\":\"data.txt\"}"}} +{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}} +{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:8}}"} +{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:10}}"} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-overwrite-read","name":"read","argumentsDelta":"{\"file_path\":\"data.txt\"}"}} +{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"} +{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"} +{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:17}}"} +{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"} +{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":10}"}} +{"turn":1,"step":3,"callId":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"} +{"turn":1,"step":2,"callId":"fs-edit-replace","name":"edit","arguments":"{\"file_path\":\"config.txt\",\"old_string\":\"DEBUG\",\"new_string\":\"RELEASE\"}"} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}} +{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"}} +{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"} +{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"} +{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," tool"," result"," I"," received"," is",":\n\n","```\n","HE","LL","O","\n","```"]} +{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"} +{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\":\"snap-1\"}"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}} +{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}} +{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-edit-read","name":"read","arguments":"{\"file_path\":\"config.txt\"}"}}} +{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}} +{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"} +{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-overwrite-read","name":"read","arguments":"{\"file_path\":\"data.txt\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}} +{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"missing-runner-output","name":"job_output","argumentsDelta":"{\"job_id\":\"bash-1\",\"wait\":true}"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and then reply with DONE."}}} +{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":10}"}}} +{"turn":1,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," second"," attempt"," succeeded","."," The"," final"," result"," is"," \"","HE","LL","O","\"."]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-delete-read-after","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-delete-read-before","name":"read","arguments":"{\"file_path\":\"deleted.txt\"}"}}} +{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}} +{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}} +{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-overwrite-write","name":"write","argumentsDelta":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"}} +{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:4}}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}} +{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}} +{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}} +{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:3}}"} +{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:5}}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} +{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-output","name":"job_output","arguments":"{\"job_id\":\"bash-1\",\"wait\":true}"}}} +{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\": \"[Console]::Out.Write('PWSH_OK')\", \"description\": \"Write PWSH_OK to console\"}"} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_root","name":"write","argumentsDelta":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} +{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:3}}"} +{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:5}}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}} +{"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":"{{message:1}}"} +{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} +{"turn":1,"point":"PreToolUse","handlerId":"claude-code:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":4.22458400000005} +{"content":[{"type":"text","text":"Use the bash tool to run exactly: false. Then reply with exactly CHILD_EXIT_PRESERVED and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":4.116542000000209} +{"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":"{{message:6}}"} +{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," has"," been"," created","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"}]} +{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","FIR","ST","\""," and"," stop","."]} +{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"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":"{{message:1}}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"}]} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]} +{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."}}} +{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"}]} +{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"content":[{"type":"text","text":"Read request event 10 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."}}} +{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_4","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} +{"turn":1,"step":1,"callId":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background job slot\",\"run_in_background\":true}"} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," then"," reply"," with"," D","ONE","."]} +{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"workspace-read","name":"bash","argumentsDelta":"{\"command\":\"cat greeting.txt\",\"description\":\"Read greeting.txt to confirm\"}"}} +{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":2.6014169999998558} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}} +{"turn":1,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["The"," replacement"," was"," successful","."," I","'ll"," reply"," with"," just"," \"","D","ONE","\""," as"," instructed","."]} +{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}} +{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," the"," single"," word"," \"","SEC","OND","\""," and"," then"," stop","."]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"partial-landlock-call","name":"bash","arguments":"{\"command\":\"false\",\"description\":\"Exit with status one\"}"}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."}}} +{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],"texts":["The"," tool"," output"," was"," rejected"," by"," code","x"," policy","."," Let"," me"," quote"," what"," I"," got"," back","."]} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]} +{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"{{message:5}}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} +{"turn":1,"step":2,"callId":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"} +{"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":"{{message:1}}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}} +{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."}}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"turn":1,"step":2,"callId":"fs-bounded-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"} +{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"{{message:4}}"}]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}} +{"turn":1,"step":2,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"workspace-read","name":"bash","arguments":"{\"command\":\"cat greeting.txt\",\"description\":\"Read greeting.txt to confirm\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HbCMzTslWBZTSphWN0z97382","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Q6wHtakaip2QNfIXaVJY5458","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"workspace-append","name":"bash","argumentsDelta":"{\"command\":\"printf 'WORLD\\n' >> greeting.txt\",\"description\":\"Append WORLD to greeting.txt\"}"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}}} +{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\", \"run_in_background\":false}"} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"}]} +{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:4}}"}]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}} +{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}} +{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:1}}"}]} +{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"} +{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," tool"," result"," was",":\n\n","```\n","HE","LL","O","\n","```\n\n","It"," completed"," successfully"," with"," exit"," code"," ","0","."]} +{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."}}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\", \"run_in_background\": false}"} +{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\", \"run_in_background\": false}"} +{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}} +{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude-code"},"role":"user","id":"{{message:5}}"}]} +{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://public.test:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}} +{"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":"{{message:1}}"} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"workspace-append","name":"bash","arguments":"{\"command\":\"printf 'WORLD\\n' >> greeting.txt\",\"description\":\"Append WORLD to greeting.txt\"}"}}} +{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:6}}"}]} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:1}}"}]} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} +{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":1}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:1}}"}]} +{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-side-effect-check","name":"bash","argumentsDelta":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\",\"run_in_background\":false}"}} +{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:1}}"}]} +{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background job\",\"run_in_background\":true}"} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"ROOT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read request event 10 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-api","name":"cordis_inspect_query","argumentsDelta":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," `","echo"," HE","LL","O","`"," using"," the"," bash"," tool"," and"," report"," the"," result"," verb","atim","."]} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-first","name":"bash","argumentsDelta":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background job slot\",\"run_in_background\":true}"}} +{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"workspace-append"},"content":[{"type":"tool-result","toolCallId":"workspace-append","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"{{message:4}}"}} +{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_root_child"},"content":[{"type":"tool-result","toolCallId":"call_root_child","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"isError":false}],"role":"user","id":"{{message:4}}"}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate one foreground subagent. Its published run will fail; report that failure as PARENT_OBSERVED_ERROR."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."}}} +{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3}} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"code-image-call"},"content":[{"type":"tool-result","toolCallId":"code-image-call","content":[{"type":"text","text":"{{cwd}}/red.png"}],"isError":false}],"role":"user","id":"{{message:4}}"}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_COMPLETED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"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":"{{message:1}}"} +{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":3,"outputTokens":3}} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"{{message:4}}"}} +{"turn":1,"step":1,"callId":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\",\"run_in_background\":false}"} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_EXIT_PRESERVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":1,"outputTokens":1}} +{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":20,"outputTokens":4}} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{message:4}}"}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ERROR"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}}} +{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_foreground","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}} +{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"RUNNER_FAILURES_SURFACED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":1,"outputTokens":1}} +{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"{{message:9}}"}} +{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}} +{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"",", ","\"","content","\"",": ","\"","hello"," world","\"","}"]} +{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," bash"," tool"," returned"," an"," error"," saying"," it"," requires"," manual"," approval"," in"," this"," session","."," I","'ll"," report"," this"," verb","atim","."]} +{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:17}}"},"usage":{"inputTokens":10,"outputTokens":2}} +{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"fs-bounded-write","name":"write","argumentsDelta":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} +{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{message:8}}"}} +{"target":"next-turn","start":0,"inserted":[{"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":"{{message:1}}"}]} +{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2}} \ No newline at end of file diff --git a/packages/session/session-persistence-sqlite/src/codec.ts b/packages/session/session-persistence-sqlite/src/codec.ts index 5711000449..9c29547904 100644 --- a/packages/session/session-persistence-sqlite/src/codec.ts +++ b/packages/session/session-persistence-sqlite/src/codec.ts @@ -1,5 +1,5 @@ /** - * Schema-18 physical chunk-row codec. This package owns the durable tags, + * Schema-19 physical chunk-row codec. This package owns the durable tags, * validation, and row-size limits independently from other persistence formats. * @module @deepseek-ai/dsh-session-persistence-sqlite/codec */ @@ -7,7 +7,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -/* jscpd:ignore-start -- schema 18 deliberately owns a frozen physical codec; +/* jscpd:ignore-start -- schema 19 deliberately owns a frozen physical codec; * importing or sharing the JSONL codec would let that format mutate this database interpreter. */ type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta' type DeltaEvent = SessionEvent<'assistant/chunk'> @@ -29,13 +29,13 @@ interface ToolCallRunData extends RunDataBase { readonly args: string[] } -/** One schema-18 packed physical record. */ +/** One schema-19 packed physical record. */ export type ChunkRow = | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData } | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData } -/** One scalar event or schema-18 packed physical record. */ +/** One scalar event or schema-19 packed physical record. */ export type StorageRecord = SessionEvent | ChunkRow /** Minimum eligible members in a packed physical record. */ @@ -174,7 +174,7 @@ function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: read } /** - * Pack eligible logical chunk runs into bounded schema-18 records. + * Pack eligible logical chunk runs into bounded schema-19 records. * @param events - logical events in sequence order. * @returns scalar and packed physical records in equivalent order. */ @@ -308,7 +308,7 @@ function expandRow(row: ChunkRow): SessionEvent[] { } /** - * Decode one scalar or packed schema-18 record. + * Decode one scalar or packed schema-19 record. * @param value - parsed physical-record value. * @returns the represented logical events. */ diff --git a/packages/session/session-persistence-sqlite/src/compression.ts b/packages/session/session-persistence-sqlite/src/compression.ts index 7102e713ff..66e3996cc5 100644 --- a/packages/session/session-persistence-sqlite/src/compression.ts +++ b/packages/session/session-persistence-sqlite/src/compression.ts @@ -5,6 +5,7 @@ * @module @deepseek-ai/dsh-session-persistence-sqlite/compression */ +import { readFileSync } from 'node:fs' import { TextDecoder } from 'node:util' import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib' import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' @@ -27,13 +28,24 @@ export interface BoundRecord { readonly isPacked: 0 | 1 } -/** Small values stay as SQLite text to avoid per-frame CPU and byte overhead. */ -export const ZSTD_DATA_THRESHOLD_BYTES = 4_096 - -const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) -const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) const ZSTD_COMPRESSION_LEVEL = 3 +const DELTA_TAG = 0 +const RUN_TAG = 1 +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) +const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n +/** + * Schema-19 raw-content zstd dictionary for independently decodable data rows. + * Its exact bytes are part of the physical format; changing the resource + * requires a schema-version bump. + */ +const ZSTD_DICTIONARY = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url)) + +/** Compress options shared by every data-column frame. */ +const DATA_ZSTD_OPTIONS = { + dictionary: ZSTD_DICTIONARY, + params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL }, +} as const const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const type ChunkTag = typeof CHUNK_TAGS[number] @@ -96,39 +108,59 @@ export function bindRecord(record: StorageRecord): BoundRecord { function encodeData(serialized: string): string | Uint8Array { const bytes = Buffer.from(serialized) - if (bytes.length < ZSTD_DATA_THRESHOLD_BYTES) return serialized - const compressed = zstdCompressSync(bytes, { - params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL }, - }) + const compressed = zstdCompressSync(bytes, DATA_ZSTD_OPTIONS) return compressed.length < bytes.length ? compressed : serialized } function decodeData(value: string | Uint8Array, maxOutputLength?: number): string { if (typeof value === 'string') return value const decoded = maxOutputLength === undefined - ? zstdDecompressSync(value) - : zstdDecompressSync(value, { maxOutputLength }) + ? zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY }) + : zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY, maxOutputLength }) return UTF8_DECODER.decode(decoded) } function encodeSourceEventSeqs(values: readonly number[]): Uint8Array { - const bytes: number[] = [] + if (values.length === 0) return new Uint8Array() + const deltas = [DELTA_TAG] let previous = 0n for (let index = 0; index < values.length; index += 1) { - const sourceSeq = values[index] as number - if (!Number.isSafeInteger(sourceSeq) || sourceSeq < 0) { + const value = values[index] as number + if (!Number.isSafeInteger(value) || value < 0) { throw new TypeError('sourceEventSeqs must contain non-negative safe integers') } - const value = BigInt(sourceSeq) + const current = BigInt(value) const encoded = index === 0 - ? value - : value >= previous - ? (value - previous) * 2n - : ((previous - value) * 2n) - 1n - appendVarint(bytes, encoded) - previous = value + ? current + : current >= previous + ? (current - previous) * 2n + : ((previous - current) * 2n) - 1n + appendVarint(deltas, encoded) + previous = current } - return Buffer.from(bytes) + if (!isStrictlyIncreasing(values)) return Uint8Array.from(deltas) + + const runs = [RUN_TAG] + let start = values[0] as number + let end = start + for (let index = 1; index < values.length; index += 1) { + const value = values[index] as number + if (value === end + 1) { + end = value + continue + } + appendVarint(runs, BigInt(start)) + appendVarint(runs, BigInt(end - start + 1)) + start = value + end = start + } + appendVarint(runs, BigInt(start)) + appendVarint(runs, BigInt(end - start + 1)) + return Uint8Array.from(runs.length < deltas.length ? runs : deltas) +} + +function isStrictlyIncreasing(values: readonly number[]): boolean { + return values.every((value, index) => index === 0 || value > (values[index - 1] as number)) } function appendVarint(bytes: number[], value: bigint): void { @@ -140,10 +172,21 @@ function appendVarint(bytes: number[], value: bigint): void { bytes.push(Number(remaining)) } -function decodeSourceEventSeqs(bytes: Uint8Array): number[] { +function decodeSourceEventSeqs(bytes: Uint8Array, maxEntries: number): number[] { + if (bytes.length === 0) return [] + if (bytes.length === 1) { + throw new Error('malformed source_event_seqs storage value: truncated tagged payload') + } + switch (bytes[0]) { + case DELTA_TAG: return decodeDeltaVarints(bytes, 1) + case RUN_TAG: return decodeRunVarints(bytes, 1, maxEntries) + default: throw new Error('malformed source_event_seqs storage value: unknown encoding tag') + } +} + +function decodeDeltaVarints(bytes: Uint8Array, offset: number): number[] { const values: number[] = [] let previous = 0n - let offset = 0 while (offset < bytes.length) { const first = values.length === 0 const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER) @@ -163,6 +206,30 @@ function decodeSourceEventSeqs(bytes: Uint8Array): number[] { return values } +function decodeRunVarints(bytes: Uint8Array, offset: number, maxEntries: number): number[] { + const values: number[] = [] + let previousEnd = -1 + while (offset < bytes.length) { + const start = readVarint(bytes, offset, MAX_SAFE_INTEGER) + const count = readVarint(bytes, start.offset, MAX_SAFE_INTEGER) + offset = count.offset + const first = Number(start.value) + const length = Number(count.value) + if (length < 1) { + throw new Error('malformed source_event_seqs storage value: run count must be positive') + } + if (first <= previousEnd || !Number.isSafeInteger(first + length - 1)) { + throw new Error('malformed source_event_seqs storage value: runs must ascend within safe integers') + } + if (length > maxEntries - values.length) { + throw new Error('malformed source_event_seqs storage value: run exceeds its event sequence') + } + for (let index = 0; index < length; index += 1) values.push(first + index) + previousEnd = first + length - 1 + } + return values +} + function readVarint( bytes: Uint8Array, offset: number, @@ -199,7 +266,7 @@ function decodeScalarRow(row: EventRow): SessionEvent { const surfaceFields = { ...row.source_event_seqs === null ? {} - : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs) }, + : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs, row.seq) }, ...row.surface_op === null ? {} : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent['surfaceOp'] }, diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 6391d94780..4c3410f8e8 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -1,6 +1,6 @@ /** * Opt-in SQLite persistence provider. Logical sessions remain unchanged; - * the physical backend packs eligible chunk runs into schema-18 rows. + * the physical backend packs eligible chunk runs into schema-19 rows. * @module @deepseek-ai/dsh-session-persistence-sqlite */ diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index 6fafb50048..6ce96e949a 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import { import { sql } from './sql.ts' /** Current physical-record schema with packed and compressed event rows. */ -export const SCHEMA_VERSION = 18 +export const SCHEMA_VERSION = 19 /** Application id reserved for DeepSeek Harness SQLite session databases. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -109,6 +109,7 @@ function configureDatabase( db: DatabaseSync, path: string, ): void { + db.exec(sql('page-size')) db.exec(sql('foreign-keys-on')) let began = false try { @@ -206,7 +207,7 @@ function initializeDatabase(db: DatabaseSync): void { db.exec(sql('schema')) db.prepare(sql('insert-persistence-state')).run(randomUUID()) db.exec(sql('set-application-id')) - db.exec(sql('set-user-version-18')) + db.exec(sql('set-user-version-19')) } let canonicalSchema: readonly SchemaObjectRow[] | undefined diff --git a/packages/session/session-persistence-sqlite/src/sql.ts b/packages/session/session-persistence-sqlite/src/sql.ts index 4b533cf57e..10814b7d65 100644 --- a/packages/session/session-persistence-sqlite/src/sql.ts +++ b/packages/session/session-persistence-sqlite/src/sql.ts @@ -19,6 +19,7 @@ const SQL_RESOURCES = [ 'journal-mode-truncate', 'journal-mode-wal', 'mmap-off', + 'page-size', 'rollback', 'schema', 'select-application-id', @@ -28,6 +29,7 @@ const SQL_RESOURCES = [ 'select-packed-predecessors', 'select-schema-objects', 'select-session', + 'select-session-key', 'select-sessions', 'select-store-id', 'select-synchronous', @@ -36,7 +38,7 @@ const SQL_RESOURCES = [ 'select-user-object-count', 'select-user-version', 'set-application-id', - 'set-user-version-18', + 'set-user-version-19', 'synchronous-full', 'trusted-schema-off', 'update-session-revision', diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts index e1f439f1f2..7a7b19f091 100644 --- a/packages/session/session-persistence-sqlite/src/store.ts +++ b/packages/session/session-persistence-sqlite/src/store.ts @@ -136,7 +136,7 @@ export class SqliteStore implements PersistenceBackend { const snapshot = this.readTransaction(() => { const row = this.rowFor(id) if (row === undefined) return undefined - const eventRows = this.db.prepare(sql('select-events')).all(id).map(decodeEventRow) + const eventRows = this.db.prepare(sql('select-events')).all(this.sessionKey(id)).map(decodeEventRow) return { row, eventRows } }) signal?.throwIfAborted() @@ -162,7 +162,7 @@ export class SqliteStore implements PersistenceBackend { const snapshot = this.readTransaction(() => { const row = this.rowFor(id) if (row === undefined) return undefined - return { row, ...this.physicalSpanFrom(id, fromSeq) } + return { row, ...this.physicalSpanFrom(this.sessionKey(id), fromSeq) } }) signal?.throwIfAborted() if (snapshot === undefined) return undefined @@ -180,17 +180,17 @@ export class SqliteStore implements PersistenceBackend { this.db.exec(sql('begin-immediate')) try { validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) - const tailRows = this.tailRows(meta.id) + const sessionKey = isMaterialized ? this.sessionKey(meta.id) : this.writeRow(meta) + const tailRows = this.tailRows(sessionKey) const currentLast = this.logicalLastEvent(meta.id, tailRows) const expected = currentLast === undefined ? 0 : currentLast.seq + 1 const first = events[0] as SessionEvent if (first.seq !== expected) { throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`) } - if (!isMaterialized) this.writeRow(meta) const insert = this.insertStatement() - for (const record of packChunkRuns(events)) this.insertRecord(insert, meta.id, bindRecord(record)) + for (const record of packChunkRuns(events)) this.insertRecord(insert, sessionKey, bindRecord(record)) this.incrementRevision(meta.id) this.db.exec(sql('commit')) } catch (error: unknown) { @@ -223,14 +223,15 @@ export class SqliteStore implements PersistenceBackend { validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) const row = this.rowFor(meta.id) if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`) - const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow) + const sessionKey = this.sessionKey(meta.id) + const currentRows = this.db.prepare(sql('select-events')).all(sessionKey).map(decodeEventRow) const current = scanRows(currentRows) if (tornMarker !== undefined) { if (current.tornFrom !== tornMarker) { throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`) } this.db.prepare(sql('delete-events-from')) - .run(meta.id, tornMarker) + .run(sessionKey, tornMarker) } else if (current.tornFrom !== undefined) { throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`) } @@ -242,7 +243,7 @@ export class SqliteStore implements PersistenceBackend { throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`) } const insert = this.insertStatement() - for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer)) + for (const closer of closers) this.insertRecord(insert, sessionKey, bindRecord(closer)) } this.incrementRevision(meta.id) this.db.exec(sql('commit')) @@ -289,6 +290,12 @@ export class SqliteStore implements PersistenceBackend { return value === undefined ? undefined : decodeSessionRow(value) } + private sessionKey(id: SessionId): number { + const row = this.db.prepare(sql('select-session-key')).get(id) as { id: number } | undefined + if (row === undefined) throw new Error(`session ${id} metadata row is missing`) + return row.id + } + private async observe(signal: AbortSignal | undefined): Promise { signal?.throwIfAborted() await this.open() @@ -327,20 +334,20 @@ export class SqliteStore implements PersistenceBackend { if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`) } - private tailRows(id: SessionId): EventRow[] { - const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse() + private tailRows(sessionKey: number): EventRow[] { + const tail = this.db.prepare(sql('select-tail-events')).all(sessionKey, 2).map(decodeEventRow).reverse() if (tail.length === 0) return [] - return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows + return this.physicalSpanFrom(sessionKey, (tail[0] as EventRow).seq).eventRows } /** Select the bounded physical span that may represent `fromSeq`. */ private physicalSpanFrom( - id: SessionId, + sessionKey: number, fromSeq: number, ): { readonly base: number; readonly eventRows: EventRow[] } { const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1) const packedPredecessors = this.db.prepare(sql('select-packed-predecessors')) - .all(id, packedFloor, fromSeq) + .all(sessionKey, packedFloor, fromSeq) .map(decodeEventRow) let base = fromSeq for (const predecessor of packedPredecessors) { @@ -352,7 +359,7 @@ export class SqliteStore implements PersistenceBackend { base = Math.min(base, predecessor.seq) } } - const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow) + const eventRows = this.db.prepare(sql('select-events-from')).all(sessionKey, base).map(decodeEventRow) return { base, eventRows } } @@ -367,9 +374,9 @@ export class SqliteStore implements PersistenceBackend { return this.db.prepare(sql('insert-event')) } - private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void { + private insertRecord(insert: StatementSync, sessionKey: number, record: BoundRecord): void { insert.run( - id, + sessionKey, record.seq, record.type, record.time, @@ -380,8 +387,8 @@ export class SqliteStore implements PersistenceBackend { ) } - private writeRow(meta: SessionHeader): void { - this.db.prepare(sql('upsert-session')).run( + private writeRow(meta: SessionHeader): number { + const inserted = this.db.prepare(sql('upsert-session')).get( meta.id, meta.version, meta.createdAt, @@ -392,7 +399,8 @@ export class SqliteStore implements PersistenceBackend { meta.delegationDepth ?? null, meta.agentPreset ?? null, randomUUID(), - ) + ) as { id: number } + return inserted.id } } diff --git a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts index f0fc765393..82dcb365a3 100644 --- a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts @@ -9,15 +9,15 @@ vi.mock('node:zlib', async (importOriginal) => { } }) -import { bindRecord, ZSTD_DATA_THRESHOLD_BYTES } from '../src/compression.ts' +import { bindRecord } from '../src/compression.ts' describe('SQLite compression fallback', () => { - it('keeps large data as text when its Zstandard frame is not smaller', () => { + it('keeps data as text when its Zstandard frame is not smaller', () => { const event = { type: 'assistant/message', seq: 0, time: 1, - data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES) }, + data: { text: 'x' }, } as unknown as SessionEvent expect(typeof bindRecord(event).data).toBe('string') diff --git a/packages/session/session-persistence-sqlite/tests/compression.spec.ts b/packages/session/session-persistence-sqlite/tests/compression.spec.ts index 237e5c08b9..bb6eeec1ca 100644 --- a/packages/session/session-persistence-sqlite/tests/compression.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/compression.spec.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { zstdCompressSync } from 'node:zlib' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' @@ -13,7 +15,6 @@ import { bindRecord, decodeRow, scanRows, - ZSTD_DATA_THRESHOLD_BYTES, } from '../src/compression.ts' import type { EventRow } from '../src/schema.ts' @@ -48,6 +49,12 @@ function row(record: StorageRecord): EventRow { } describe('SQLite compression', () => { + it('pins the schema-19 dictionary bytes', () => { + const dictionary = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url)) + expect(createHash('sha256').update(dictionary).digest('hex')) + .toBe('dad18fa0247a8fdd886a62d8552eabd36cbd50c25af172873080d2f0ae770d17') + }) + it('stores a 100-member run in one row and restores every logical event', () => { const events = Array.from({ length: 100 }, (_, index) => chunk(index)) const records = packChunkRuns(events) @@ -160,7 +167,7 @@ describe('SQLite compression', () => { expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/) }) - it('decodes the schema-18 row vocabulary without another package codec', () => { + it('decodes the schema-19 row vocabulary without another package codec', () => { const fixture: EventRow = { seq: 7, type: 'text-chunks', @@ -211,19 +218,34 @@ describe('SQLite compression', () => { }, ) - it('compresses large data and delta-encodes complete provenance arrays', () => { + it('compresses small repetitive data with the shared dictionary', () => { + const event = { + type: 'tool/result', + seq: 1, + time: 2, + data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'hello world '.repeat(40) }] } }, + sourceEventSeqs: [0], + surfaceOp: 'append', + } as unknown as SessionEvent + const bound = bindRecord(event) + expect(bound.data).toBeInstanceOf(Uint8Array) + expect(decodeRow(row(event))).toEqual([event]) + }) + + it('compresses large data and run-encodes consecutive provenance arrays', () => { const sources = Array.from({ length: 2_000 }, (_, index) => index + 10) const event = { type: 'assistant/message', seq: sources.at(-1)! + 1, time: 1, - data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES * 2) }, + data: { text: 'x'.repeat(8_192) }, sourceEventSeqs: sources, surfaceOp: 'append', } as unknown as SessionEvent const bound = bindRecord(event) expect(bound.data).toBeInstanceOf(Uint8Array) expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array) + expect(bound.sourceEventSeqs?.[0]).toBe(1) expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources))) expect(decodeRow(row(event))).toEqual([event]) @@ -234,6 +256,7 @@ describe('SQLite compression', () => { it('round-trips empty, descending, and maximum-safe provenance deltas', () => { for (const sources of [ [], + [1, 3, 4, 5, 10], [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2], ]) { const event = { @@ -248,6 +271,19 @@ describe('SQLite compression', () => { } }) + it('does not impose a persistence-only provenance length limit', () => { + const sources = Array.from({ length: 1_000_001 }, (_, index) => index) + const event = { + type: 'assistant/message', + seq: sources.length, + time: 1, + data: {}, + sourceEventSeqs: sources, + surfaceOp: 'append', + } as unknown as SessionEvent + expect(bindRecord(event).sourceEventSeqs?.[0]).toBe(1) + }) + it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => { const event = { type: 'assistant/message', @@ -263,20 +299,36 @@ describe('SQLite compression', () => { it('rejects malformed compressed and delta-encoded values', () => { const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow() - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80]) })) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00]) })) + .toThrow(/truncated tagged payload/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01]) })) + .toThrow(/truncated tagged payload/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x02, 0x00]) })) + .toThrow(/unknown encoding tag/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80]) })) .toThrow(/truncated varint/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80, 0x00]) })) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x80, 0x00]) })) .toThrow(/non-canonical varint/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x01]) })) + // tag 0, first value 0, then a negative delta (zigzag 0x01) from 0 + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x00, 0x01]) })) .toThrow(/decoded seq is out of range/) + // tag 0, first value MAX_SAFE_INTEGER, then a positive delta overflowing it expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, ]) })).toThrow(/decoded seq is out of range/) expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([ - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, + 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, ]) })).toThrow(/varint is out of range/) - expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.alloc(9, 0x80) })) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.concat([ + Buffer.from([0x00]), Buffer.alloc(9, 0x80), + ]) })) .toThrow(/varint is out of range/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x01]) })) + .toThrow(/run exceeds its event sequence/) + expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x01, 0x00, 0x00]) })) + .toThrow(/run count must be positive/) + expect(() => decodeRow({ ...scalar, seq: 2, source_event_seqs: Buffer.from([0x01, 0x00, 0x01, 0x00, 0x01]) })) + .toThrow(/runs must ascend/) }) it('rejects an oversized packed data column before JSON decoding', () => { diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql index a20ce28b7d..31a525b4e6 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql @@ -11,4 +11,4 @@ CREATE TABLE events ( INSERT INTO persistence_state (singleton, store_id) VALUES (1, '00000000-0000-4000-8000-000000000000'); PRAGMA application_id = 1146308688; -PRAGMA user_version = 18; +PRAGMA user_version = 19; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql index c8f86c2f8f..262d42cdb1 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql @@ -1,2 +1,2 @@ DELETE FROM events -WHERE session_id = ?; +WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql index 3dab681f92..09e7059c93 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql @@ -1,2 +1,2 @@ INSERT INTO events (session_id, seq, type, time, data, is_packed) -VALUES (?, ?, ?, ?, ?, ?); +VALUES ((SELECT id FROM sessions WHERE session_key = ?), ?, ?, ?, ?, ?); diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql index 9ae7f2e92d..5cf3b0352e 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql @@ -1,4 +1,4 @@ SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, is_packed FROM events -WHERE session_id = ? +WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) ORDER BY seq; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql index 14b6e17742..1f842e8e8f 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql @@ -1,5 +1,5 @@ SELECT seq, type, data FROM events -WHERE session_id = ? +WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?) ORDER BY seq DESC LIMIT 1; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql new file mode 100644 index 0000000000..cac8d70e58 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-page-size.sql @@ -0,0 +1 @@ +PRAGMA page_size; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql new file mode 100644 index 0000000000..5f78e691ec --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-page-size-4096.sql @@ -0,0 +1 @@ +PRAGMA page_size = 4096; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql new file mode 100644 index 0000000000..c1082b72f4 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-19.sql @@ -0,0 +1 @@ +PRAGMA user_version = 19; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql index a4d6b530a2..2c34aff7f5 100644 --- a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql @@ -1,3 +1,3 @@ UPDATE sessions SET origin = 'external', delegation_depth = -1, seed_length = -1 -WHERE id = ?; +WHERE session_key = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql new file mode 100644 index 0000000000..00c684b79d --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/vacuum.sql @@ -0,0 +1 @@ +VACUUM; diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index cd27fb7e3d..304e748de0 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -300,6 +300,7 @@ describe('SessionPersistenceSqlite physical packing', () => { const db = new DatabaseSync(path) expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION }) + expect(db.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 65_536 }) expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 }) expect(db.prepare(testSql('count-packed-events')).get()) .toEqual({ count: 1 }) @@ -379,6 +380,22 @@ describe('SessionPersistenceSqlite physical packing', () => { .rejects.toThrow(/schema version 17.*incompatible/) }) + it('keeps the page size of an established schema 19 database', async () => { + const path = await freshDbPath('dsh-sqlite-page-size-') + const seed = await openDatabase(DatabaseSync, path, 'delete', DEFAULT_BUSY_TIMEOUT_MS) + seed.close() + + const resize = new DatabaseSync(path) + resize.exec(testSql('set-page-size-4096')) + resize.exec(testSql('vacuum')) + expect(resize.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 4_096 }) + resize.close() + + const reopened = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS) + expect(reopened.prepare(testSql('select-page-size')).get()).toEqual({ page_size: 4_096 }) + reopened.close() + }) + it('rejects a stale physical append without replacing the winning tail', async () => { const path = await freshDbPath('dsh-sqlite-stale-') const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) @@ -392,6 +409,21 @@ describe('SessionPersistenceSqlite physical packing', () => { await second.close() }) + it('rolls back lazy integer-key materialization after a rejected append', async () => { + const store = new SqliteStore({ + path: await freshDbPath('dsh-sqlite-key-rollback-'), + journalMode: 'wal', + busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS, + }) + const header = meta(SessionId('key-rollback')) + + await expect(store.appendBatch(header, [chunk(1)], false)).rejects.toThrow(/stored next seq is 0/) + await expect(store.appendBatch(header, [chunk(0)], true)).rejects.toThrow(/metadata row is missing/) + await expect(store.appendBatch(header, [chunk(0)], false)).resolves.toBeUndefined() + expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)]) + await store.close() + }) + it('rejects a stale repair without deleting a newer winning tail', async () => { const path = await freshDbPath('dsh-sqlite-stale-repair-') const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS }) @@ -537,7 +569,7 @@ describe('SessionPersistenceSqlite schema ownership', () => { const foreignPath = await freshDbPath('dsh-sqlite-foreign-') const foreign = new DatabaseSync(foreignPath) - foreign.exec(testSql('set-user-version-18')) + foreign.exec(testSql('set-user-version-19')) foreign.exec(testSql('set-application-id-12345')) foreign.close() await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/) diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts index f6be9cdb4a..93723af0ae 100644 --- a/packages/session/session-persistence-sqlite/tests/test-sql.ts +++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts @@ -16,15 +16,19 @@ export type TestSqlName = | 'measure-write-traffic' | 'replace-events-with-nonstrict-table' | 'select-last-event' + | 'select-page-size' | 'select-event-rowids' | 'select-event-rows' | 'select-user-version' | 'set-application-id-12345' + | 'set-page-size-4096' | 'set-user-version-15' | 'set-user-version-16' | 'set-user-version-17' | 'set-user-version-18' + | 'set-user-version-19' | 'update-invalid-session-metadata' + | 'vacuum' /** Load one fixed test SQL resource. */ export function testSql(name: TestSqlName): string { diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 9889bbbd7a..4afccf5a3a 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -196,13 +196,16 @@ describe('SessionProjectionCache write policy', () => { }) it('flushes on the configured interval when the count threshold is not reached', async () => { - const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } }) + const { ctx, cache } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } }) + const write = vi.spyOn(cache, 'write').mockResolvedValue() + vi.useFakeTimers() const session = ctx.sessions.create(SessionId('interval')) + write.mockClear() mark(session, ['slow']) - await new Promise(resolve => setTimeout(resolve, 10)) // before the interval - expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut - await settle() // past the interval; the fire-and-forget write lands - expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] }) + await vi.advanceTimersByTimeAsync(19) + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(write).toHaveBeenCalledExactlyOnceWith(session) }) it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index bf7a695c16..e807931e2c 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/README.md -README.md: cbc6f3c2cc00a1d71e917f27b6b2b145630cefc3 -README.zh.md: 91ad0cc787c84a98c2f5080c33b2a10e2f96dc24 +README.md: 76df70351d711d580d3ab1a89d0f929b85eab172 +README.zh.md: c4deb001c47435d29a5ca18cc0f8c0d26e48941e diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index cbc6f3c2cc..76df70351d 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -81,6 +81,8 @@ This section explains how the service is built and where the observable behavior | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary | | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers | | [`src/list-children.ts`](src/list-children.ts) | Discovery over the live session store and optional persistence | +| [`src/control.ts`](src/control.ts) | Browser control assembly: catalog activity sampling, browser-zone validation, failure codes | +| [`src/control-types.ts`](src/control-types.ts) | Client-safe catalog row, control requests, receipts, and failures | ### One-shot flow diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 91ad0cc787..c4deb001c4 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -81,6 +81,8 @@ kind: "package-reference" | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 | | [`src/list-children.ts`](src/list-children.ts) | 基于在线会话存储与可选持久化的发现 | +| [`src/control.ts`](src/control.ts) | 浏览器控制面组装:目录活性采样、浏览器时区校验、失败分码 | +| [`src/control-types.ts`](src/control-types.ts) | client-safe 的目录行、控制面请求、回执与失败 | ### 一次性流程 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 5cdb75b630..c89f11eee7 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -26,6 +26,14 @@ "types": "./lib/types/client.d.ts", "default": "./lib/types/client.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -33,7 +41,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "dependencies": { @@ -56,6 +68,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -108,6 +121,7 @@ "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts index 602dcd8793..8a643d6284 100644 --- a/packages/subagent/subagent/src/client.ts +++ b/packages/subagent/subagent/src/client.ts @@ -1,7 +1,8 @@ /** - * Browser-safe subagent projection vocabulary. + * Browser-safe subagent projection and control vocabulary. * * @module @deepseek-ai/dsh-subagent/client */ +export type * from './control-types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts new file mode 100644 index 0000000000..ea02674413 --- /dev/null +++ b/packages/subagent/subagent/src/control-types.ts @@ -0,0 +1,147 @@ +/** + * Client-safe subagent catalog and control vocabulary: the durable direct-child + * row both the listing and the browser catalog answer with, plus the + * browser-facing control surface's prompt, receipts, and failures. + * + * @module @deepseek-ai/dsh-subagent/control-types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { z as zCore } from 'zod' + +/** + * Client-minted identity of one browser prompt, persisted on the exact accepted + * message. It carries the Session Controller's `session-request-id` brand so a + * subagent prompt and an ordinary Session prompt share one identity + * vocabulary; that package depends on this one, so the brand is spelled here + * rather than imported. + */ +export type SubagentPromptRequestId = Branded<'session-request-id'> + +/** + * One durable direct-child row, ordered by header `createdAt` with ties broken + * on id. Only a candidate whose durable header has `origin: 'subagent'` is + * interpreted. A served `subagent` projection value produces a `child`; a + * settled candidate whose fold served no identity produces a `diagnostic`; a + * running candidate without one is omitted — its descriptor may not be + * appended yet (the creation window). Diagnostics relay the projection fold's + * outcome or a failed read, never a per-child event scan, and never expose + * model-hidden descriptor content. + */ +export type SubagentListEntry = + | { + readonly kind: 'child' + /** The durable child session id, stable across Activations. */ + readonly id: SessionId + /** + * Whether the child is live at the moment its reader sampled it: the + * durable listing reads the Session store (`running` means the logical + * record is resident, `inactive` that it exists only in persistence), + * while the browser catalog re-samples the child's Agent driver. Neither + * encodes a durable outcome, and a continuable child may still reject + * delivery as an ownership conflict. + */ + readonly activity: 'running' | 'inactive' + /** Whether a direct descendant has durable `origin: 'subagent'`. */ + readonly hasChildren: boolean + } & ( + | { + /** A terminal one-shot child. */ + readonly mode: 'one-shot' + /** Optional durable creation label from the child's descriptor. */ + readonly label?: string + } + | { + /** A resumable conversation. */ + readonly mode: 'continuable' + /** Durable creation label from the child's descriptor. */ + readonly label: string + } + ) + | { + readonly kind: 'diagnostic' + /** The candidate's session id. */ + readonly id: SessionId + /** + * Why the candidate has no `child` row: `corrupt` for a settled candidate + * whose projection fold served no identity (a missing, malformed, or + * unrecognized-version descriptor — deliberately undistinguished), and + * for any candidate whose log makes a registered unit's fold or schema + * throw (deterministic data damage, contained per child); `unavailable` + * when the candidate's Session observation was absent or transiently + * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the + * union for consumers that route on it. + */ + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } + +/** Complete direct-child catalog plus the delivery-time parent availability hint. */ +export interface SubagentCatalog { + readonly entries: readonly SubagentListEntry[] + readonly parentAvailable: boolean +} + +/** Durable parent/child address that selects subagent transport in the client. */ +export type SubagentAddress = + & { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + & ( + | { readonly mode: 'one-shot' } + | { readonly mode: 'continuable' } + ) + +/** One human message addressed to a continuable direct child. */ +export interface SubagentPromptRequest { + /** Identity persisted on the accepted message, minted before the call. */ + readonly requestId: SubagentPromptRequestId + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + /** Required discriminator retained from the browser control address. */ + readonly mode: 'continuable' + /** Content delivered as the child's user message. */ + readonly content: ContentBlock[] + /** Optional browser zone sampled for this exact human prompt. */ + readonly clientTimeZone?: string +} + +/** Inbox identity returned once the continuation accepts one human message. */ +export interface SubagentPromptReceipt { + readonly messageId: MessageId +} + +/** Uniform acknowledgement that one interrupt request was admitted. */ +export interface SubagentInterruptReceipt { + readonly accepted: true +} + +/** + * Failure details the control surface answers with. The catalog read, the + * prompt, and the interrupt produce these codes; a Client fabricates + * `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot + * address it refuses before the call, so both planes read one vocabulary. + */ +export interface SubagentControlErrorDetailsMap { + 'bad-request': { readonly issues: zCore.core.$ZodIssue[] } + cancelled: Record + 'invalid-time-zone': { readonly value: string } + 'subagent-parent-unavailable': { readonly parentSessionId: SessionId } + 'subagent-not-resumable': { readonly childSessionId: SessionId } + 'subagent-unauthorized': { readonly childSessionId: SessionId } + 'subagent-delivery-unavailable': { readonly childSessionId: SessionId } + 'subagent-projections-unavailable': Record + internal: Record +} + +/** One subagent control failure, returned without a carrier error. */ +export type SubagentControlError = { + [Code in keyof SubagentControlErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SubagentControlErrorDetailsMap[Code] + } +}[keyof SubagentControlErrorDetailsMap] diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts new file mode 100644 index 0000000000..661a43153a --- /dev/null +++ b/packages/subagent/subagent/src/control.ts @@ -0,0 +1,180 @@ +/** + * Browser-facing subagent control assembly: the catalog view sampled against + * the live Agent registry, one browser zone's validation, and the stable + * failure codes the Remote surface answers with. + * + * @module @deepseek-ai/dsh-subagent + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import type { + SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry, +} from './control-types.ts' +import { SubagentError } from './error.ts' + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +const SESSION_ID_SCHEMA = z.string().min(1) +const CONTROL_ID_SCHEMAS = { + 'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }), + 'subagent.prompt': z.object({ + parentSessionId: SESSION_ID_SCHEMA, + childSessionId: SESSION_ID_SCHEMA, + mode: z.literal('continuable'), + }), + 'subagent.interrupt': z.object({ + parentSessionId: SESSION_ID_SCHEMA, + childSessionId: SESSION_ID_SCHEMA, + mode: z.literal('continuable'), + }), +} as const + +/** + * Validate and canonicalize one browser-supplied IANA zone at the wire boundary. + * @param value - the browser's reported zone name. + * @returns the canonical zone, or `undefined` when the name is unusable. + */ +export function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions().timeZone + /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined + return canonical + } catch { + // Intl rejects unsupported zone names; the caller maps that parser rejection. + return undefined + } +} + +/** + * Refuse one Remote call with a stable business failure the carrier preserves. + * @param code - declared caller-facing code. + * @param message - human-readable refusal. + * @param details - that code's declared detail payload. + * @returns Never — the failure is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectControl( + code: Code, + message: string, + details: SubagentControlErrorDetailsMap[Code], +): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +/** + * Apply the subagent payload checks that are stricter than generated + * branded-string codecs. + * @param method - method name carried in the failure message. + * @param payload - decoded control fields to validate. + * @throws {TypertRemoteFailure} `bad-request` with the original Zod issues. + */ +export function validateControlRequest( + method: keyof typeof CONTROL_ID_SCHEMAS, + payload: unknown, +): void { + const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload) + if (!parsed.success) { + return rejectControl('bad-request', `invalid payload for ${method}`, { + issues: parsed.error.issues, + }) + } +} + +/** + * Project one durable listing onto the catalog view, replacing each row's + * store-derived activity with the live Agent driver's status and reporting + * whether the exact parent Agent is live. Without an Agent registry no driver + * runs at all, so every row is inactive and the parent is unavailable. + * @param ctx - Host context that may carry the Agent registry. + * @param parentSessionId - the listed parent. + * @param entries - the durable direct-child listing. + * @returns the catalog view answered to one browser. + */ +export function catalogView( + ctx: Context, + parentSessionId: SessionId, + entries: readonly SubagentListEntry[], +): SubagentCatalog { + const agents = ctx.get('agents') + return { + entries: entries.map((entry): SubagentListEntry => entry.kind === 'child' + ? { ...entry, activity: agents?.get(entry.id)?.status === 'running' ? 'running' : 'inactive' } + : entry), + parentAvailable: agents?.get(parentSessionId) !== undefined, + } +} + +/** + * Refuse one catalog read while preserving cancellation and a missing + * projections registry as distinct failures. + * @param error - the thrown value. + * @param signal - the caller's cancellation. + * @returns Never — the refusal is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { + if (isCancellation(error, signal)) { + return rejectControl('cancelled', 'subagent catalog read was cancelled', {}) + } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return rejectControl( + 'subagent-projections-unavailable', + 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + {}, + ) + } + return rejectControl('internal', 'subagent catalog read failed', {}) +} + +/** + * Refuse one continuation prompt without exposing provider detail: admission + * failures the caller can act on keep their own code, everything else is + * internal. + * @param error - the thrown value. + * @param childSessionId - the addressed child. + * @param signal - the caller's cancellation. + * @returns Never — the refusal is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never { + if (isCancellation(error, signal)) { + return rejectControl('cancelled', 'subagent prompt was cancelled', {}) + } + if (error instanceof SubagentError) { + switch (error.code) { + case 'NOT_RESUMABLE': + return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId }) + case 'UNAUTHORIZED': + return rejectControl( + 'subagent-unauthorized', + 'subagent does not belong to this parent', + { childSessionId }, + ) + case 'DRAINING': + case 'ACTIVATION_CLOSING': + case 'CONTINUATION_UNAVAILABLE': + case 'PERSISTENCE_UNAVAILABLE': + return rejectControl( + 'subagent-delivery-unavailable', + 'subagent follow-up is temporarily unavailable', + { childSessionId }, + ) + // A code outside the admission vocabulary is not the caller's move to make. + default: + break + } + } + return rejectControl('internal', 'subagent prompt failed', {}) +} + +function isCancellation(error: unknown, signal: AbortSignal): boolean { + return signal.aborted || (error instanceof SubagentError && error.code === 'CANCELLED') +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0701ff7b90..444f4ee857 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,13 +29,25 @@ * @module @deepseek-ai/dsh-subagent */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt, + validateControlRequest, +} from './control.ts' +import type { + SubagentCatalog, + SubagentInterruptReceipt, + SubagentPromptReceipt, + SubagentPromptRequest, + SubagentPromptRequestId, +} from './control-types.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, @@ -121,7 +133,8 @@ export type { SubagentSettledMessageSource, } from './continuation.ts' export type { ContinuableSetupContribution } from './activation-setup-registry.ts' -export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' +export type * from './control-types.ts' +export type { SubagentDescendantListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' @@ -166,8 +179,21 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Durable attribution of one browser-authored follow-up. The Session + * Controller declares this `user-rpc` message source and depends on this + * package, so the fields are spelled here: `MessageSource`'s `user` member + * accepts the record and the correlation id rides the durable message the + * Client reconciles its optimistic prompt against. + */ +interface BrowserPromptSource { + readonly kind: 'user' + readonly rpcId: SubagentPromptRequestId + readonly clientTimeZone?: string +} + /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ -export class SubagentRuntime extends Service { +export class SubagentRuntime extends TypertRemoteService { private providers = new Map() private continuations: SubagentContinuationManager | undefined /** Deployment contributions composed into unpublished continuable children. */ @@ -363,6 +389,114 @@ export class SubagentRuntime extends Service { return listSubagentDescendants(this.ctx, rootSessionId, signal) } + /** + * Remote face of {@link listChildren} for one browser: the durable listing + * plus live Agent activity and the delivery-time parent availability hint. + * Parent availability is a hint; {@link prompt} performs the authoritative + * check. Named apart from the provider-name {@link list}, which owns the + * member. + * @param parentSessionId - parent session whose direct children are listed. + * @param signal - carrier cancellation forwarded to Session queries. + * @returns the catalog view for that parent. + * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, + * `cancelled` for an aborted read, `subagent-projections-unavailable` when + * the deployment has no projection registry, otherwise `internal`. + */ + @Remote('list') + async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise { + validateControlRequest('subagent.list', { parentSessionId }) + try { + return catalogView(this.ctx, parentSessionId, await this.listChildren(parentSessionId, signal)) + } catch (error: unknown) { + return rejectCatalogRead(error, signal) + } + } + + /** + * Deliver one browser-authored message to a continuable child through the + * exact live direct parent, retaining the caller-minted request identity and + * validated browser zone on the accepted message. Success identifies the + * message the child's FIFO inbox accepted; later execution is independent of + * this call. + * @param request - durable address, minted identity, content, and optional browser zone. + * @param signal - carrier cancellation, owning the call until inbox acceptance. + * @returns the accepted message's inbox identity. + * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, + * `subagent-parent-unavailable`, `subagent-not-resumable`, + * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or + * `internal`. + */ + @Remote('prompt') + async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise { + const { parentSessionId, childSessionId, clientTimeZone } = request + validateControlRequest('subagent.prompt', request) + const canonicalTimeZone = clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(clientTimeZone) + if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { + return rejectControl( + 'invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: clientTimeZone }, + ) + } + const parent = this.ctx.get('agents')?.get(parentSessionId) + if (parent === undefined) { + return rejectControl( + 'subagent-parent-unavailable', + `parent session "${parentSessionId}" is not live`, + { parentSessionId }, + ) + } + const source: BrowserPromptSource = { + kind: 'user', + rpcId: request.requestId, + ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), + } + const content: ContentBlock[] = [...request.content] + try { + return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } + } catch (error: unknown) { + return rejectPrompt(error, childSessionId, signal) + } + } + + /** + * Remote face of {@link interrupt} under one durable parent address. No + * catalog, history, persistence, or parent Agent lookup runs: the core + * primitive alone authorizes the address against the live Activation, which + * is what keeps a live child interruptible while its parent Agent is offline. + * Absent, idle, and already-completed targets are accepted no-ops there. + * @param childSessionId - durable child session id to interrupt. + * @param parentSessionId - durable direct parent whose authority is claimed. + * @param mode - required continuable-address discriminator. + * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, + * `subagent-unauthorized` when the address does not own the live target, + * otherwise `internal`. + */ + @Remote('interruptByParent') + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: 'continuable', + ): SubagentInterruptReceipt { + validateControlRequest('subagent.interrupt', { childSessionId, parentSessionId, mode }) + try { + this.interrupt(childSessionId, { kind: 'user', parentSessionId }) + } catch (error: unknown) { + if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { + return rejectControl( + 'subagent-unauthorized', + 'subagent does not belong to this parent', + { childSessionId }, + ) + } + return rejectControl('internal', 'subagent interrupt failed', {}) + } + return { accepted: true } + } + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index ffefa624b8..b04289d8bf 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -21,9 +21,12 @@ import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' +import type { SubagentListEntry } from './control-types.ts' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' +export type { SubagentListEntry } from './control-types.ts' + /** * Concurrent cold observations per explicit catalog listing. Current Session * persistence providers are local; a networked provider must promote this to @@ -31,61 +34,6 @@ import type { SubagentIdentityProjection } from './projection-types.ts' */ const COLD_READ_CONCURRENCY = 4 -/** - * One entry of a {@link listChildren} result, ordered by header `createdAt` - * with ties broken on id. Only a candidate whose durable header has - * `origin: 'subagent'` is interpreted. A served `subagent` projection value - * produces a `child`; a settled candidate whose fold served no identity - * produces a `diagnostic`; a running candidate without one is omitted — its - * descriptor may not be appended yet (the creation window). Diagnostics - * relay the projection fold's outcome or a failed read, never a per-child - * event scan, and never expose model-hidden descriptor content. - */ -export type SubagentListEntry = - | { - readonly kind: 'child' - /** The durable child session id, stable across Activations. */ - readonly id: SessionId - /** - * Store snapshot activity: `running` means the logical record is live in - * `ctx.sessions`; `inactive` means it exists only in persistence. Neither - * encodes a durable outcome, and a continuable child may still reject - * delivery as an ownership conflict. - */ - readonly activity: 'running' | 'inactive' - /** Whether a direct descendant has durable `origin: 'subagent'`. */ - readonly hasChildren: boolean - } & ( - | { - /** A terminal one-shot child. */ - readonly mode: 'one-shot' - /** Optional durable creation label from the child's descriptor. */ - readonly label?: string - } - | { - /** A resumable conversation. */ - readonly mode: 'continuable' - /** Durable creation label from the child's descriptor. */ - readonly label: string - } - ) - | { - readonly kind: 'diagnostic' - /** The candidate's session id. */ - readonly id: SessionId - /** - * Why the candidate has no `child` row: `corrupt` for a settled candidate - * whose projection fold served no identity (a missing, malformed, or - * unrecognized-version descriptor — deliberately undistinguished), and - * for any candidate whose log makes a registered unit's fold or schema - * throw (deterministic data damage, contained per child); `unavailable` - * when the candidate's Session observation was absent or transiently - * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the - * union for consumers that route on it. - */ - readonly reason: 'corrupt' | 'unsupported' | 'unavailable' - } - /** * One entry of a descendant listing: the interpreted subagent facts plus its * position in the complete session tree. `parentId` is the durable direct diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts new file mode 100644 index 0000000000..dd04d549b1 --- /dev/null +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -0,0 +1,301 @@ +// The browser-facing control surface: catalog assembly against the live Agent +// registry, prompt admission, and the stable failure codes each answers with. +// The durable listing, continuation, and interrupt primitives they wrap have +// their own specs, so each case scripts them. + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentRuntime, { + SubagentError, + type SubagentListEntry, + type SubagentPromptRequestId, +} from '@deepseek-ai/dsh-subagent' + +const PARENT = SessionId('parent') +const CHILD = SessionId('child') +const OTHER = SessionId('other') +const BROKEN = SessionId('broken') +const REQUEST_ID = 'req-1' as SubagentPromptRequestId +const signal = new AbortController().signal + +/** The runtime plus a programmable live-Agent registry, omitted to compose none. */ +async function bench(live?: Record) { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + if (live !== undefined) { + ctx.provide('agents', { get: (id: SessionId) => live[id] } as never) + } + return { ctx, subagents: ctx.subagents } +} + +function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentListEntry { + return { kind: 'child', id, mode: 'continuable', label: 'worker', activity, hasChildren: false } +} + +function promptRequest(clientTimeZone?: string) { + return { + requestId: REQUEST_ID, + parentSessionId: PARENT, + childSessionId: CHILD, + mode: 'continuable' as const, + content: [{ type: 'text' as const, text: 'continue' }], + ...clientTimeZone === undefined ? {} : { clientTimeZone }, + } +} + +function emptyIdFailure(method: string, field: string) { + return { + code: 'bad-request', + message: `invalid payload for ${method}`, + details: { + issues: [{ + origin: 'string', + code: 'too_small', + minimum: 1, + inclusive: true, + path: [field], + message: 'Too small: expected string to have >=1 characters', + }], + }, + } +} + +describe('subagent catalog Remote', () => { + it('rejects an empty parent id before listing', async () => { + const { subagents } = await bench() + const listChildren = vi.spyOn(subagents, 'listChildren') + + await expect(subagents.remoteExportList(SessionId(''), signal)) + .rejects.toMatchObject({ failure: emptyIdFailure('subagent.list', 'parentSessionId') }) + expect(listChildren).not.toHaveBeenCalled() + }) + + it('samples row activity from the live Agent driver and reports parent availability', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' }, [CHILD]: { status: 'running' } }) + vi.spyOn(subagents, 'listChildren').mockResolvedValue([ + // The durable listing reports store presence; the browser row reports the driver. + childRow(CHILD, 'inactive'), + childRow(OTHER, 'running'), + { kind: 'diagnostic', id: BROKEN, reason: 'corrupt' }, + ]) + + await expect(subagents.remoteExportList(PARENT, signal)).resolves.toEqual({ + entries: [ + childRow(CHILD, 'running'), + childRow(OTHER, 'inactive'), + { kind: 'diagnostic', id: BROKEN, reason: 'corrupt' }, + ], + parentAvailable: true, + }) + }) + + it('reports every row inactive and the parent unavailable without an Agent registry', async () => { + const { subagents } = await bench() + vi.spyOn(subagents, 'listChildren').mockResolvedValue([childRow(CHILD, 'running')]) + + await expect(subagents.remoteExportList(PARENT, signal)).resolves.toEqual({ + entries: [childRow(CHILD, 'inactive')], + parentAvailable: false, + }) + }) + + it('reports an unknown parent as unavailable while the registry serves other sessions', async () => { + const { subagents } = await bench({ [CHILD]: { status: 'running' } }) + vi.spyOn(subagents, 'listChildren').mockResolvedValue([]) + + await expect(subagents.remoteExportList(PARENT, signal)) + .resolves.toEqual({ entries: [], parentAvailable: false }) + }) + + it('separates cancellation, the missing projections capability, and an unexplained read failure', async () => { + const { subagents } = await bench() + const listChildren = vi.spyOn(subagents, 'listChildren') + + const aborted = new AbortController() + aborted.abort() + listChildren.mockRejectedValue(new Error('read stopped')) + await expect(subagents.remoteExportList(PARENT, aborted.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + + listChildren.mockRejectedValue(new SubagentError('cancelled', 'CANCELLED')) + await expect(subagents.remoteExportList(PARENT, signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + + listChildren.mockRejectedValue( + new SubagentError('no registry', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE'), + ) + await expect(subagents.remoteExportList(PARENT, signal)).rejects.toMatchObject({ + failure: { + code: 'subagent-projections-unavailable', + message: expect.stringContaining('sessionProjections') as unknown as string, + }, + }) + + listChildren.mockRejectedValue(new Error('disk gone')) + await expect(subagents.remoteExportList(PARENT, signal)) + .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent catalog read failed' } }) + }) +}) + +describe('subagent prompt Remote', () => { + it('rejects empty parent and child ids before delivery', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup') + + const cases: readonly { + readonly field: 'parentSessionId' | 'childSessionId' + readonly request: ReturnType + }[] = [ + { field: 'parentSessionId', request: { ...promptRequest(), parentSessionId: SessionId('') } }, + { field: 'childSessionId', request: { ...promptRequest(), childSessionId: SessionId('') } }, + ] + for (const { field, request } of cases) { + await expect(subagents.prompt(request, signal)) + .rejects.toMatchObject({ failure: emptyIdFailure('subagent.prompt', field) }) + } + expect(followup).not.toHaveBeenCalled() + }) + + it('forwards non-text content blocks without narrowing them', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-content' as MessageId) + const content = [{ type: 'reasoning' as const, text: 'retain this block' }] + + await expect(subagents.prompt({ ...promptRequest(), content }, signal)) + .resolves.toEqual({ messageId: 'm-content' }) + expect(followup.mock.calls[0]?.[2]).toEqual(content) + }) + + it('delivers the content under the caller-minted identity and canonical browser zone', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-1' as MessageId) + + await expect(subagents.prompt(promptRequest('Asia/Shanghai'), signal)) + .resolves.toEqual({ messageId: 'm-1' }) + expect(followup).toHaveBeenCalledWith( + { status: 'idle' }, + CHILD, + [{ type: 'text', text: 'continue' }], + { + source: { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' }, + signal, + }, + ) + }) + + it('omits the zone from the durable source when the browser reported none', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-2' as MessageId) + + await expect(subagents.prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-2' }) + expect(followup.mock.calls[0]?.[3].source).toEqual({ kind: 'user', rpcId: REQUEST_ID }) + }) + + it('accepts UTC and rejects an empty, untrimmed, malformed, or unknown zone', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + vi.spyOn(subagents, 'followup').mockResolvedValue('m-3' as MessageId) + + await expect(subagents.prompt(promptRequest('UTC'), signal)).resolves.toEqual({ messageId: 'm-3' }) + for (const zone of ['', ' UTC', 'Shanghai', 'Nowhere/Nowhere']) { + await expect(subagents.prompt(promptRequest(zone), signal)).rejects.toMatchObject({ + failure: { code: 'invalid-time-zone', details: { value: zone } }, + }) + } + }) + + it('refuses delivery when the exact parent Agent is not live', async () => { + const { subagents } = await bench() + const followup = vi.spyOn(subagents, 'followup') + + await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ + failure: { code: 'subagent-parent-unavailable', details: { parentSessionId: PARENT } }, + }) + expect(followup).not.toHaveBeenCalled() + }) + + it('maps each admission failure onto its stable code and hides the rest', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup') + const cases: readonly [string, string][] = [ + ['NOT_RESUMABLE', 'subagent-not-resumable'], + ['UNAUTHORIZED', 'subagent-unauthorized'], + ['DRAINING', 'subagent-delivery-unavailable'], + ['ACTIVATION_CLOSING', 'subagent-delivery-unavailable'], + ['NO_PROVIDER', 'internal'], + ] + for (const [thrown, code] of cases) { + followup.mockRejectedValue(new SubagentError('refused', thrown)) + await expect(subagents.prompt(promptRequest(), signal)) + .rejects.toMatchObject({ failure: { code } }) + } + + followup.mockRejectedValue(new Error('inbox exploded')) + await expect(subagents.prompt(promptRequest(), signal)) + .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent prompt failed' } }) + }) + + it('answers a caller-cancelled delivery as cancelled rather than a failure', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const aborted = new AbortController() + vi.spyOn(subagents, 'followup').mockImplementation(() => { + aborted.abort() + return Promise.reject(new SubagentError('gone', 'NOT_RESUMABLE')) + }) + + await expect(subagents.prompt(promptRequest(), aborted.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + }) + + it('preserves a cancellation reported by the continuation operation', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + vi.spyOn(subagents, 'followup') + .mockRejectedValue(new SubagentError('stopped', 'CANCELLED')) + + await expect(subagents.prompt(promptRequest(), signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + }) +}) + +describe('subagent interrupt Remote', () => { + it('rejects empty child and parent ids before interrupting', async () => { + const { subagents } = await bench() + const interrupt = vi.spyOn(subagents, 'interrupt') + + for (const [childSessionId, parentSessionId] of [ + [SessionId(''), PARENT], + [CHILD, SessionId('')], + ] as const) { + const field = childSessionId.length === 0 ? 'childSessionId' : 'parentSessionId' + expect(() => subagents.interruptByParent(childSessionId, parentSessionId, 'continuable')) + .toThrow(expect.objectContaining({ + failure: emptyIdFailure('subagent.interrupt', field), + })) + } + expect(interrupt).not.toHaveBeenCalled() + }) + + it('admits the parent-addressed interrupt and acknowledges it', async () => { + const { subagents } = await bench() + const interrupt = vi.spyOn(subagents, 'interrupt').mockReturnValue() + + expect(subagents.interruptByParent(CHILD, PARENT, 'continuable')).toEqual({ accepted: true }) + expect(interrupt).toHaveBeenCalledWith(CHILD, { kind: 'user', parentSessionId: PARENT }) + }) + + it('answers a foreign address as unauthorized and everything else as internal', async () => { + const { subagents } = await bench() + const interrupt = vi.spyOn(subagents, 'interrupt') + + interrupt.mockImplementation(() => { throw new SubagentError('not yours', 'UNAUTHORIZED') }) + expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( + expect.objectContaining({ failure: { code: 'subagent-unauthorized', message: expect.any(String) as unknown as string, details: { childSessionId: CHILD } } }), + ) + + interrupt.mockImplementation(() => { throw new Error('boom') }) + expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( + expect.objectContaining({ failure: { code: 'internal', message: 'subagent interrupt failed', details: {} } }), + ) + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index ae39b9a78b..4c86dd8250 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -56,6 +56,9 @@ { "path": "../../jobs/jobs" }, + { + "path": "../../typert/protocol" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 84613f30ac..76d5f08c4a 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -63,6 +64,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index b269eb9bf4..986bfd266d 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -9,7 +9,7 @@ import type { SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem, SessionSnapshot, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index 91a07be843..f1e2f9a8d1 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../client/ui-settings" }, + { + "path": "../../subagent/subagent" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index 5358acb1b7..981c653b0a 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -12,7 +12,7 @@ import { delimiter as pathDelimiter } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-compaction' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session' +import { decodeSeqRanges, decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session' import type { ContentBlock, GenerateOptions, @@ -170,9 +170,10 @@ export interface SessionScript { /** * Parse a session `.jsonl` buffer into its event list. Line 0 is the session * header (a `{type:'session',…}` record), every subsequent non-empty line is a - * {@link SessionEvent} or a packed chunk row (expanded back into its events, so - * a fixture recorded with `packChunks` on derives the same script). The header - * is skipped; malformed lines fail loud. + * {@link SessionEvent} or a packed chunk row. Packed rows expand back into + * events, and JSONL storage-form provenance ranges expand back into + * `number[]`, so physical fixture encodings derive the same script. The + * header is skipped; malformed lines fail loud. * @param text - the raw `.jsonl` file contents. * @returns every event after the header, in log order. */ @@ -206,6 +207,9 @@ export function parseSessionLog(text: string): SessionEvent[] { if (!Object.hasOwn(record, timeKey)) record[timeKey] = 0 let decoded: SessionEvent[] try { + if (Object.hasOwn(record, 'sourceEventSeqs')) { + record.sourceEventSeqs = decodeSeqRanges(record.sourceEventSeqs) + } decoded = decodeStorageRecord(record) } catch (error) { /* v8 ignore next -- decodeStorageRecord only throws Error instances; the String arm satisfies unknown narrowing. */ diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index c06ac28add..3b8f679ec5 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -103,6 +103,28 @@ describe('parseSessionLog', () => { expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev]) }) + it('expands range-encoded source provenance', () => { + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) + const event = { + ...chunkEvent(4, 1, 1, TEXT_CHUNKS[0] as StreamChunk), + sourceEventSeqs: [[1, 3], 5], + } + expect(parseSessionLog(`${header}\n${JSON.stringify(event)}\n`)).toEqual([{ + ...event, + sourceEventSeqs: [1, 2, 3, 5], + }]) + }) + + it('reports malformed range provenance with its source line', () => { + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) + const event = { + ...chunkEvent(4, 1, 1, TEXT_CHUNKS[0] as StreamChunk), + sourceEventSeqs: [[3, 1]], + } + expect(() => parseSessionLog(`${header}\n${JSON.stringify(event)}\n`)) + .toThrow('session snapshot line 2: sourceEventSeqs ranges require start <= end') + }) + it('rejects non-object body rows with their source line', () => { const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) expect(() => parseSessionLog(`${header}\nnull\n`)) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 3274c0398f..35b72fc16e 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-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/test-support/session-snapshot/README.md -README.md: 979dcc06e8a2249e89a2dc7c63503c21ffb237f7 -README.zh.md: 02d04a6145e51b916baefd76c6cd611f709962ae +README.md: 9f1907af8b9e50f587bcc05b93d8e3730c8f272c +README.zh.md: 2d4290578a655df91eb60f63177c97fc5d82da6c diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 979dcc06e8..9f1907af8b 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -102,7 +102,7 @@ This section explains the design of the kit; the observable behavior is fully co ### Design -The shared core owns manifests, workspace setup/comparison, typed identity mapping, normalizers, and fixture invariants. The ACP adapter adds four composable layers: launcher, scenario harness, normalizers, and suite factory. `launchAcpTestAgent` boots a source profile under tsx or a built `lib` profile under plain Node, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns shutdown. `runScenario` drives ACP JSON-RPC stdio and harvests every persisted raw JSONL session log. The pure normalizers replace cwd paths and typed identities with stable tokens, zero times, and scrub request-header bulk. `defineAcpSnapshotSuite` registers comparisons, fixture write-back, and the live uniformity guard. +The shared core owns manifests, workspace setup/comparison, typed identity mapping, normalizers, and fixture invariants. The ACP adapter adds four composable layers: launcher, scenario harness, normalizers, and suite factory. `launchAcpTestAgent` boots a source profile under tsx or a built `lib` profile under plain Node, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns shutdown. `runScenario` drives ACP JSON-RPC stdio and harvests every persisted raw JSONL session log. The pure normalizers replace cwd paths and typed identities with stable tokens, zero times, expand physical provenance ranges, and scrub request-header bulk. `defineAcpSnapshotSuite` registers comparisons, fixture write-back, and the live uniformity guard. ### Source map diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 02d04a6145..2d4290578a 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -102,7 +102,7 @@ defineAcpSnapshotSuite({ ### 设计 -共享核心拥有 manifest、workspace 设置/比较、类型化身份映射、规范化器与 fixture 不变式。ACP 适配器增加四个可组合层:启动器、场景 harness、规范化器与套件工厂。`launchAcpTestAgent` 在 tsx 下启动源码 profile,或在普通 Node 下启动已构建 `lib` profile,通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新与 stderr,默认拒绝未处理的权限请求,并负责关闭。`runScenario` 驱动 ACP JSON-RPC stdio,并收集每个持久化原始 JSONL 会话日志。纯规范化器把 cwd 路径与类型化身份变为稳定 token,将时间归零,并擦除请求 header bulk。`defineAcpSnapshotSuite` 注册比较、fixture 回写与实时一致性保护。 +共享核心拥有 manifest、workspace 设置/比较、类型化身份映射、规范化器与 fixture 不变式。ACP 适配器增加四个可组合层:启动器、场景 harness、规范化器与套件工厂。`launchAcpTestAgent` 在 tsx 下启动源码 profile,或在普通 Node 下启动已构建 `lib` profile,通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新与 stderr,默认拒绝未处理的权限请求,并负责关闭。`runScenario` 驱动 ACP JSON-RPC stdio,并收集每个持久化原始 JSONL 会话日志。纯规范化器把 cwd 路径与类型化身份变为稳定 token,将时间归零、展开物理来源区间,并擦除请求 header bulk。`defineAcpSnapshotSuite` 注册比较、fixture 回写与实时一致性保护。 ### 源码地图 diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index 441c5376f5..8073e9430b 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -7,6 +7,7 @@ */ import { + decodeSeqRanges, decodeStorageRecord, packChunkRuns, type SessionEvent, @@ -367,6 +368,9 @@ export function normalizeSessionLog( if ('createdAt' in data) data.createdAt = 0 if ('updatedAt' in data) data.updatedAt = 0 } + if (Object.hasOwn(record, 'sourceEventSeqs')) { + record.sourceEventSeqs = decodeSeqRanges(record.sourceEventSeqs) + } return scrubValue(record, ctx, cwdPathMode, identityMode) as Record }) return records.map(r => JSON.stringify(r)).join('\n') + '\n' diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index 27e40367ca..e033f56ee2 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -547,6 +547,19 @@ describe('normalizeSessionSnapshot', () => { ].join('\n')]) }) + it('projects persisted provenance ranges back to logical seq arrays', () => { + const raw = [ + JSON.stringify({ type: 'session', version: 0 }), + JSON.stringify({ + type: 'assistant/message', + sourceEventSeqs: [[1, 3], 5], + surfaceOp: 'append', + data: { turn: 1, step: 1 }, + }), + ].join('\n') + '\n' + expect(normalizeSessionSnapshot(raw, ctx)).toContain('"sourceEventSeqs":[1,2,3,5]') + }) + it('rejects headerless input', () => { expect(() => normalizeSessionSnapshot('{"type":"turn/start"}\n', ctx)) .toThrow('session snapshot must start with a session header') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c08862556..c9ec69d20a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,6 +722,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval @@ -5715,9 +5718,6 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto @@ -8244,6 +8244,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval @@ -9052,6 +9055,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index d9036ec78c..f464732b40 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -160,8 +160,12 @@ const packageFileExtras: Readonly> = { // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], - // SQLite loads every statement from immutable package resources at runtime. - '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'], + // SQLite loads its compression dictionary and every statement from immutable + // package resources at runtime. + '@deepseek-ai/dsh-session-persistence-sqlite': [ + 'resources/zstd-dictionary.bin', + 'resources/sql/**/*.sql', + ], '@deepseek-ai/dsh-skill-badge': ['assets'], // tsdown shares the repository/pack code between the lib entry and the bin // through a hashed chunk. The committed bin.js is the link target pnpm can diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fc040769e1..00359ad450 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -465,10 +465,14 @@ export const LINK_MAP: Readonly> = { ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', + SubagentCatalog: 'subagent.md', SubagentDescendantListEntry: 'subagent.md', SubagentFollowupOptions: 'subagent.md', SubagentInterruptAuthority: 'subagent.md', + SubagentInterruptReceipt: 'subagent.md', SubagentListEntry: 'subagent.md', + SubagentPromptReceipt: 'subagent.md', + SubagentPromptRequest: 'subagent.md', SubagentProvider: 'subagent.md', SubagentReportDelivery: 'subagent.md', SubagentReportMessageSource: 'subagent.md', diff --git a/scripts/session-fixture-layout.spec.ts b/scripts/session-fixture-layout.spec.ts index 2be3f469ca..ddff386615 100644 --- a/scripts/session-fixture-layout.spec.ts +++ b/scripts/session-fixture-layout.spec.ts @@ -76,13 +76,25 @@ describe('canonicalSessionFixture', () => { }) describe('isPhysicalSessionFixture', () => { - it('excludes only persisted logs under the WebWorker example root', () => { + it('recognizes fixtures that preserve physical persistence encoding', () => { expect(isPhysicalSessionFixture( 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/main/session.jsonl', )).toBe(true) + expect(isPhysicalSessionFixture( + 'scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl', + )).toBe(true) + expect(isPhysicalSessionFixture( + 'scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl', + )).toBe(true) + expect(isPhysicalSessionFixture( + 'scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl', + )).toBe(true) expect(isPhysicalSessionFixture( 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/README.jsonl', )).toBe(false) + expect(isPhysicalSessionFixture( + 'scripts/snapshots/python-sdk-single-exe/advanced/requests.jsonl', + )).toBe(false) expect(isPhysicalSessionFixture('apps/web/tests/snapshots/example/session.jsonl')).toBe(false) }) }) diff --git a/scripts/session-fixture-layout.ts b/scripts/session-fixture-layout.ts index e26cb7f90b..b26935ccec 100644 --- a/scripts/session-fixture-layout.ts +++ b/scripts/session-fixture-layout.ts @@ -8,9 +8,13 @@ import { packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' /** Physical persistence artifacts validated by the WebWorker runtime fixture spec. */ -const PHYSICAL_SESSION_FIXTURE_ROOT = +const WEBWORKER_PHYSICAL_SESSION_FIXTURE_ROOT = 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/' +/** Installed-runtime snapshots that preserve the JSONL writer's physical encoding. */ +const PYTHON_RUNTIME_PHYSICAL_SESSION_FIXTURE_ROOT = + 'scripts/snapshots/python-sdk-single-exe/' + /** One repository session fixture and its canonical projected representation. */ export interface SessionFixtureLayout { /** Repository-relative path with `/` separators. */ @@ -22,13 +26,17 @@ export interface SessionFixtureLayout { } /** - * Whether a repository JSONL is a production-layout persistence artifact rather - * than an envelope-free replay snapshot owned by this script. + * Whether a repository JSONL preserves physical persistence encoding rather + * than the logical event projection owned by this script. * @param path - Repository-relative path with `/` separators. - * @returns True only for Session logs under the WebWorker VFS example root. + * @returns True for physical WebWorker and installed-runtime session logs. */ export function isPhysicalSessionFixture(path: string): boolean { - return path.startsWith(PHYSICAL_SESSION_FIXTURE_ROOT) && path.endsWith('/session.jsonl') + if (path.startsWith(WEBWORKER_PHYSICAL_SESSION_FIXTURE_ROOT)) { + return path.endsWith('/session.jsonl') + } + return path.startsWith(PYTHON_RUNTIME_PHYSICAL_SESSION_FIXTURE_ROOT) + && /\/session(?:\.\d+)?\.jsonl$/.test(path) } function isSessionHeader(value: unknown): boolean { diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index fd4d16b2fb..27dc66824f 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -18,6 +18,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[14,18]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 8010510efb..771a6da579 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -18,6 +18,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[14,18]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index bf4259f171..ee10730da7 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -17,7 +17,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[13,17]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -28,7 +28,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[24,28]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[30],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} @@ -40,7 +40,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[36,40]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} @@ -53,7 +53,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[49,53]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} {"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} @@ -64,7 +64,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[60,64]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}} @@ -79,7 +79,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[75,79]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}} {"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[81],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} @@ -91,6 +91,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[87,88,89,90,91],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[87,91]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl index f372003e39..826c568492 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl @@ -17,6 +17,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[13,17]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl index ee4adb9bf0..8f7b088786 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl @@ -17,6 +17,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[[13,17]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}