mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into worktree/composer-editable-gate
# Conflicts: # apps/web/tests/permission-policy-context.e2e.ts
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
浏览器侧,驱动插件每帧重载一个插件,串行执行:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.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/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.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.
|
||||
|
||||
@@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
|
||||
|
||||
## 决定
|
||||
|
||||
**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
|
||||
**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.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 套件旁拥有自己的行为用例。
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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 `<base href="/">` 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 `<base href="/">` 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.
|
||||
|
||||
+2
-2
@@ -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 期渲染 `<base href="/">` 锚定深层 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 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
|
||||
- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。
|
||||
|
||||
+6
@@ -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
|
||||
+70
@@ -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.
|
||||
+70
@@ -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 容器,以及本笔记恢复的标准压缩级别策略。
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<void>((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 {
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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<T> = { 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<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
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<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
const endpoint = `session/${method}`
|
||||
/** POST one generated Remote unary through the API Gateway carrier. */
|
||||
async function remote<T>(
|
||||
scaffold: WebScaffold,
|
||||
endpoint: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
): Promise<RpcResult<T>> {
|
||||
const response = await scaffold.hostFetch(`/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -48,13 +35,18 @@ async function sessionRemote<T>(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<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
return remote<T>(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<void> {
|
||||
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()
|
||||
|
||||
@@ -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<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
@@ -41,16 +53,24 @@ async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
|
||||
}, { 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<HTMLElement>('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<HTMLElement>('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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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 自身从不监视文件。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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,而不是执行迁移。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -648,6 +648,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
|
||||
*/
|
||||
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
|
||||
|
||||
/**
|
||||
* 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<SubagentCatalog>
|
||||
|
||||
/**
|
||||
* 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<SubagentPromptReceipt>
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -652,6 +652,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
|
||||
*/
|
||||
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
|
||||
|
||||
/**
|
||||
* 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<SubagentCatalog>
|
||||
|
||||
/**
|
||||
* 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<SubagentPromptReceipt>
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
|
||||
@@ -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<void>> {
|
||||
for (const contribution of [
|
||||
agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
|
||||
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
|
||||
sessionRemote, workspaceRemote,
|
||||
subagentsRemote, sessionRemote, workspaceRemote,
|
||||
]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
|
||||
@@ -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<T> =
|
||||
@@ -13,7 +14,7 @@ export type ClientResult<T> =
|
||||
|
||||
/**
|
||||
* 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<T>(error: unknown): ClientResult<T> {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RemoteResult<object | undefined>>
|
||||
}
|
||||
|
||||
/** Narrow subagent namespace consumed by a Client Session and its manager. */
|
||||
export interface SessionSubagentsRemote {
|
||||
list(parentSessionId: SessionId, signal?: AbortSignal): Promise<RemoteResult<SubagentCatalog>>
|
||||
prompt(
|
||||
request: SubagentPromptRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<SubagentPromptReceipt>>
|
||||
interruptByParent(
|
||||
childSessionId: SessionId,
|
||||
parentSessionId: SessionId,
|
||||
mode: 'continuable',
|
||||
): Promise<RemoteResult<SubagentInterruptReceipt>>
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -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<SessionSelection>(
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -85,6 +85,7 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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<T>(error: RpcError): RpcResponse<T> {
|
||||
}
|
||||
|
||||
/** Successful generated Remote result for programmable domain fakes. */
|
||||
function remoteOk<T>(value: T): RemoteResult<T> {
|
||||
export function remoteOk<T>(value: T): RemoteResult<T> {
|
||||
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<T>(error: RemoteFailure): RemoteResult<T> {
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
type ValueStreamItem<F> =
|
||||
| { kind: 'frame'; value: F; delivered?: () => void }
|
||||
| { kind: 'end' }
|
||||
@@ -189,19 +200,13 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
|
||||
= () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId }))
|
||||
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => 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<RemoteResult<SubagentInterruptReceipt>>
|
||||
= () => 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)),
|
||||
|
||||
@@ -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<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FixtureSessionApi['list']>[0],
|
||||
)
|
||||
@@ -3591,9 +3595,6 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<unknown>> {
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 通道。
|
||||
|
||||
### 浏览器侧替换
|
||||
|
||||
|
||||
@@ -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<ClientArtifactBaseline, 'path'>
|
||||
type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
|
||||
|
||||
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<string, WatchedBundle>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<string, string>, 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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -1152,4 +1152,5 @@ export class SessionStore extends Service {
|
||||
|
||||
}
|
||||
|
||||
export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts'
|
||||
export default SessionStore
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 <file> [--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.
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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 <file> [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。
|
||||
|
||||
@@ -49,6 +49,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。
|
||||
- **可达性只推断精确请求形式**——计算得到的 `import` 与 `require` 参数、保存下来的 `createRequire` 结果、经 CommonJS 获取的 `createRequire`,以及基准不是 `import.meta.url` 的调用只在运行时解析;若目标已被裁掉就会立即失败。只能通过这些形式触达的目标需要显式镜像入口种子。
|
||||
- **vendored 包源码(`src/*.ts`)被排除**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。
|
||||
- **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 客户端。
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class Transformer {
|
||||
private moduleSyntax = false
|
||||
private readonly moduleRequests = new Set<string>()
|
||||
private readonly metaResolveRequests = new Set<string>()
|
||||
private readonly createRequireBindings = new Set<string>()
|
||||
|
||||
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[]
|
||||
/**
|
||||
|
||||
@@ -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<string, string>
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<SubagentCatalog>',
|
||||
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<SubagentPromptReceipt>',
|
||||
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<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\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<ContinuableStart>;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise<MessageId>;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
|
||||
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise<MessageId>;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\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<Service extends object = object> {\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<AsyncIterable<unknown>>;\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<TypertRemoteEventDispatch>;',
|
||||
},
|
||||
{
|
||||
name: 'TypertRemoteService',
|
||||
declaration: 'export abstract class TypertRemoteService<out T = never> extends Service<T> {\n readonly typertRemote: TypertGatewayBinding<this>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 的入口,不挂载本包。
|
||||
|
||||
### 请求流
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<never> {
|
||||
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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -49,16 +49,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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<RpcError>
|
||||
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Wire<SubagentListEntry>>
|
||||
|
||||
/** subagent.list request payload. */
|
||||
export const subagentListRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
|
||||
|
||||
/** subagent.list response value. */
|
||||
export const subagentListValueSchema = z.object({
|
||||
entries: z.array(subagentListEntrySchema),
|
||||
parentAvailable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
|
||||
|
||||
/** 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<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
/** subagent.interrupt request payload. */
|
||||
export const subagentInterruptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
|
||||
|
||||
/** subagent.interrupt response value. */
|
||||
export const subagentInterruptValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
|
||||
|
||||
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
|
||||
|
||||
/** subagent.prompt response value. */
|
||||
export const subagentPromptValueSchema = z.object({
|
||||
messageId: messageIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>
|
||||
@@ -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<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* 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<SubagentAddress, { mode: 'continuable' }> & {
|
||||
content: ContentBlock[]
|
||||
/** Optional browser zone sampled for this exact human prompt. */
|
||||
clientTimeZone?: string
|
||||
}
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
|
||||
/**
|
||||
* 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<Extract<SubagentAddress, { mode: 'continuable' }>>,
|
||||
): Promise<RpcResponse<SubagentInterruptReceipt>>
|
||||
}
|
||||
@@ -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<RpcResponse<ResponseValue<'subagent.list'>>>
|
||||
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
|
||||
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
@@ -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<Wire<ResponseValue<K>>> } = {
|
||||
'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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user