diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 0a007fc184..9d49d9b636 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b316d1a42c4f69c8e1163f1f36249b61a7d126ef -2026-08-02-typert-remote-method-calls.zh.md: 02714de3c522ce597e89976b6e03d455ff0bf872 +2026-08-02-typert-remote-method-calls.md: 04d4581a8f9a265e5374dae4b76ac8d7e1f91fc6 +2026-08-02-typert-remote-method-calls.zh.md: d0940d0cd95b04901caafa214058a272b23ded5d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b316d1a42c..04d4581a8f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. -The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. +The business package's published files must include `lib/typert.remote-client.d.ts.map`. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it. That target is a development-time path: a workspace consumer resolves it through the package link, so the published payload keeps excluding `src` and a published map simply resolves nothing. Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 02714de3c5..d0940d0cd9 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一约定的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 -业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 +业务 package 的发布文件必须包含 `lib/typert.remote-client.d.ts.map`。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责发布它。该目标是开发期路径:workspace 消费者经 package link 解析它,因此发布产物仍然不含 `src`,已发布的 map 只是解析不到东西。 仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index b7fc5cca1d..4c832ba450 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 3e7ff9c3bd58b73185c669a224a59cd017ecfb42 -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: a4255f8e29b696e84ff3accf636714cdfc0855f1 +2026-08-04-declaring-a-provider-from-the-models-page.md: 179a31396d34228e7a0cd510edac4c535af3162e +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 3fca60554769fbf5f5be0d2bc76edf3ab3b71578 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 3e7ff9c3bd..179a31396d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `` that takes `.input` without `.selectInput`, because the OS arrow it would otherwise keep sits flush inside the 240px cap `select.input` imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md index a4255f8e29..3fca605547 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -48,4 +48,4 @@ Status: implemented ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码,任何只取 `.input` 而不取 `.selectInput` 的 `` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml new file mode 100644 index 0000000000..a3249dcddb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md +2026-08-11-loader-entry-disabled-interpolation.md: fd760ea0f15f19e5f287aaddc36fb8eeb5f519ba +2026-08-11-loader-entry-disabled-interpolation.zh.md: 15f5a80931c58555dceab359d05e8515334513b2 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md new file mode 100644 index 0000000000..fd760ea0f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md @@ -0,0 +1,25 @@ +# Agent Note: Loader interpolates the entry `disabled` field + +Status: implemented + +English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) + +## Problem + +The Windows platform layer (then a separate `windows.cordis.patch.yml` beside the base patch, since folded into the base rows — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. + +## Decision + +The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) declare the shell tool rows themselves and gate them by platform — `tool-bash` with `disabled: !!js process.platform === 'win32'` and its `tool-pwsh` twin with the inverted expression — so the preset layer exposes exactly one shell tool per host; the web-app overlay disables the host rows of both tools, letting each session's preset decide. `verify-cordis-config` now allows expressions in `disabled` only. + +The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs. + +## Alternatives considered + +**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. + +**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows. + +## Consequences + +A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The shell TOOL rows follow the same one-plane rule as every other preset-declared row: the web-app overlay disables the host `tool-bash`/`tool-pwsh` rows and the presets declare both with inverted platform gates, so a preset can drop or replace the shell tool per session on either host. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md new file mode 100644 index 0000000000..15f5a80931 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -0,0 +1,25 @@ +# Agent Note:Loader 插值条目 `disabled` 字段 + +Status: implemented + +[English](2026-08-11-loader-entry-disabled-interpolation.md) | 中文 + +## 问题 + +Windows 平台层(当时是 base patch 旁独立的 `windows.cordis.patch.yml`,现已折入 base 行——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 + +## 决策 + +Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)自己声明 shell 工具行并按平台门控——`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,其孪生行 `tool-pwsh` 以取反的表达式——因此预设层每台宿主恰好暴露一个 shell 工具;web-app overlay 禁用两个工具的 host 行,由每个会话的预设决定。`verify-cordis-config` 现在只允许 `disabled` 中的表达式。 + +该机制补全了平台层折叠:base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。 + +## 备选方案 + +**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 + +**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。 + +## 后果 + +行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上:win32 挂载受限 pwsh 栈,POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。shell 工具行遵循与其他预设声明行相同的 one-plane 规则:web-app overlay 禁用 host 面的 `tool-bash`/`tool-pwsh` 行,预设以互逆的平台门控声明两者,因此任一宿主的每个会话都可以按预设丢弃或替换 shell 工具。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 852d3816e2..95b52ad014 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 8ad5d801358823576c37d6b6824d7ce3fdcdac11 -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: d6d2c64703df95ea2a4ff79435512eabdb3d2f81 +2026-07-28-themed-scrollbars-and-reserved-gutter.md: a820a92406ce4054f16922064772d03c6ac3ab83 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 80104c4aca5986c3a1f49186ac4adf3169f46da5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 8ad5d80135..a820a92406 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were initially missed, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index d6d2c64703..80104c4aca 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,7 +20,7 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 `Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面最初被漏掉,因此逐样式表的重新绑定约定由机械检查而非人工审阅把关。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml index 6e364d9a3e..4db552343c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md -2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 -2026-07-30-hover-popup-pointer-grace.zh.md: f53d77bae7b7f223621c4c42779e1a6a616aa485 +2026-07-30-hover-popup-pointer-grace.md: 302592a2e9de8e749ef2450e8877a3ccbffd15d5 +2026-07-30-hover-popup-pointer-grace.zh.md: 4020a6f1f7ab52b6340a07f4c099906a7353a8a5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md index 3f60c98ec6..302592a2e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -32,4 +32,4 @@ The hover card is now hit-testable and covers 244px of whatever it overlays whil ## Testing -`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md index f53d77bae7..4020a6f1f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -32,4 +32,4 @@ Status: implemented ## 测试 -`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 8245d59db4..82df356c6b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 1c65eeec0fbf0dc550043bb809ae8d7310f0602b -2026-07-30-web-transcript-log-ordered-projection.zh.md: 67bb41adde46ba7112896404ddc92a05f08f7aca +2026-07-30-web-transcript-log-ordered-projection.md: f2eda6983ab88a420a4506be7766320dc5da0fa2 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 4c8bb5d0b9b49762d12aba837cef9c0818740cb0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 1c65eeec0f..f2eda6983a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 67bb41adde..4c8bb5d0b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index d9a9857cc8..92dcd28a7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 7a2500a6253c7cf50a57458b66c9b80503414ace +2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index ba11384409..6097779529 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -71,7 +71,7 @@ Revealing the caret is the one thing that now depends on the browser rather than ## Testing -The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. +The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures the rest in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls. Every metric is read in the caret's own coordinate frame — where the textarea places line n, offset included — against a DOM Range over the backdrop's text for the same line, because that difference is what a user sees. The decisive case changes the offset and re-reads that difference **before the task ends**, which is before any `scroll` listener could have run: 0 with one scrollport, and the full delta with a mirror. A vacuity guard asserts the draft overflows the capped box first, and separate cases cover the cap, one wrap width across all three layers, a wheel gesture, a trailing-newline draft, and the caret-reveal path that the textarea's own scrolling used to handle — typing after scrolling away must bring the scrollport back to the caret. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 7a2500a625..753d67d538 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -71,7 +71,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl ## 测试 -[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 +[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区的空白 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index d40263ad98..2a780fd889 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: 10ded250cec9c92d90803bdf5969cc7f5aa54c50 -2026-08-05-context-meter-blind-to-compaction.zh.md: ca3fe59eecde82bb97b44af4c3383546d5672753 +2026-08-05-context-meter-blind-to-compaction.md: b90208eda25101aeb55130cd1b6563dbfaee8a9a +2026-08-05-context-meter-blind-to-compaction.zh.md: 85387c50ebc981fc2f7f02d07432da677f69ef90 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index 10ded250ce..b90208eda2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -43,4 +43,4 @@ The panel's composition rows still do not sum to the header, and now for one cle ## Testing -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.client.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index ca3fe59eec..85387c50eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -43,4 +43,4 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 测试 -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.client.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index e82314c28b..41d1df6605 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 0581b768679d78716c1bf70cfffec825c7281783 +2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 +2026-08-05-workspace-blank-session-reuse-membership.zh.md: e67810dc3a45d494f64a2b774557d04297b6fce3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 910a10e9ad..0d46a0ccf6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 0581b76867..e67810dc3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml index f789ed37a0..33f4d398c9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md -2026-08-06-onboarding-step-owned-takeover-chrome.md: 35b9d16aaba4ca9f124a9972c43b2a107ea0309f -2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 2f3331965aa94aa01bf0ce21d4bbb421397ee5bd +2026-08-06-onboarding-step-owned-takeover-chrome.md: 97f5335412f256bf7e60f36731c9e0c4475ee99e +2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 054072764a7b9a25586899a93596fdc922e71dcb diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md index 35b9d16aab..97f5335412 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md @@ -32,4 +32,4 @@ A future step that registers without wrapping its visible content in `Onboarding ## Testing -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md index 2f3331965a..054072764a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md @@ -32,4 +32,4 @@ ## 测试 -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index d7e6553fd3..f25c5622fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: ef5ddbeb9bea1393f474dfb4c809ae2e19bfea5c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 7a1142cdd793cfce46bfd3908ab28345701aec7a +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ce7cd6d0ade2702a6f717340cb7c9b354d0387c2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index ef5ddbeb9b..66a1ca361c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 7a1142cdd7..ce7cd6d0ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml new file mode 100644 index 0000000000..2b04b5b240 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +2026-08-07-code-mode-executor-collapse.md: 3b64675f90f830628dcde427d4388153ec7accf1 +2026-08-07-code-mode-executor-collapse.zh.md: 51b35ad44b9758805784834d1d036ba374140dd9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md new file mode 100644 index 0000000000..3b64675f90 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md @@ -0,0 +1,46 @@ +# Agent Note: Code Mode collapses the executor, not just the wire + +Status: implemented + +English | [中文](2026-08-07-code-mode-executor-collapse.zh.md) + +## Problem + +`mode: 'code'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing. + +The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor. + +## Decision + +`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `code`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. + +Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. + +The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `code` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [Code Mode foundation](../feature/2026-06-15-code-mode.md) owns the transport design this note layers the execution boundary onto. + +## Alternatives considered + +### Filter `get()` / the registry view by mode + +The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor. + +### Filter at the agent-loop entry + +The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns. + +### Reject via a shipped guard + +Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself. + +### Keep schema omission only (status quo) + +No provider guarantees interception of unadvertised names; the reported session proves it does not happen. + +## Consequences + +- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). +- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). +- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. +- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls. +- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of the 100-199 guidance band. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt. +- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md new file mode 100644 index 0000000000..51b35ad44b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md @@ -0,0 +1,46 @@ +# Agent Note: Code Mode 塌缩执行器而非仅通告面 + +Status: implemented + +[English](2026-08-07-code-mode-executor-collapse.md) | 中文 + +## 问题 + +`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 + +包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 + +## 决策 + +`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `code` 时,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现,其消息会指出改走 `run_code` 的正确路径——因为这个名字对当前模型而言是**已声明过**的(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。有效的 scope 模式包括从 agent preset 继承的声明,因此其 wire schema 与执行权限保持一致。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 + +执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 + +塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [Code Mode 基础](../feature/2026-06-15-code-mode.md) 之上,传输设计由后者拥有。 + +## 备选方案 + +### 按模式过滤 `get()` / 注册表视图 + +视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。 + +### 在 agent-loop 入口过滤 + +loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。 + +### 通过内置 guard 拒绝 + +guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。 + +### 只保留 schema 省略(维持现状) + +没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。 + +## 后果 + +- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 +- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 +- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 +- 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 +- 提示词会**声明**这条塌缩,位于排在 100–199 指导段之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 +- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 67d2b330b1..72878df41d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 6b9cdf32b7..eb21094f0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet. + +That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index e6e8dabcd8..02c245348a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。 + +该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 9d13851a0d..231768828d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e -2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 +2026-07-20-dsh-cli-personal-config.md: bc2aff322de01bb9c6beebb1679b2ff9909d1fe3 +2026-07-20-dsh-cli-personal-config.zh.md: 507a7188a4a77d904e3204499290f3ed22abab2c diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index ed04725e92..bc2aff322d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md) ## Problem -A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. +A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index cc97987f80..507a7188a4 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 +开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」(AGENTS.md);但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 5c05f0b86d..bf3d66fcf3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: bb66ef8512badea090b3b22030eef3f43f3b1119 -2026-07-23-web-todo-display.zh.md: 5b3534e20956a4412814a782532d1337c08ff027 +2026-07-23-web-todo-display.md: 3d6720bca80cbcfa4d163d91c0fdffe5e7ed624f +2026-07-23-web-todo-display.zh.md: 692993731d921ed9502dc66d1ca60e1ed0648c0a diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index bb66ef8512..3d6720bca8 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 5b3534e209..692993731d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -33,4 +33,4 @@ Status: implemented ## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 097c0c9f2d..015852b082 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 +2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803 +2026-07-25-session-list-browsing-and-manual-order.zh.md: a6c44579c685479ca460da8e52ea885f20e4776b diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 3d2125bdf6..52a0fe0c94 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority. ### Row interactions @@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, ## Consequences -- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. - Wiring session Delete and growing the wire status enum remain future iterations. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 161ebd2857..a6c44579c6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。 ### 行交互 @@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin ## Consequences -- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞。 +- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 - 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 - session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index c0813607d3..8d8d36e87d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df -2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c +2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4 +2026-07-25-workspace-ui-product-flow.zh.md: e15ead7b437d8f2324f7ea51222eb4fcfb4a9e4a diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 98e9631951..76d279bf21 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. @@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr ### Sidebar and ordering -Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. +Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups. -Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. +The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). -A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. +The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows. Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. @@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. - The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. -- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. -- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect. +- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count. - The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences -- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration. - Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. - Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. - Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 486093be0b..e15ead7b43 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace,并返回完整的已提交顺序 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时先使用当前 Session 所属 Workspace,再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 @@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame ### Sidebar 与排序 -Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 +Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位,`workspace.insertBefore` 则持久应用用户拖拽顺序;Session 活跃不会移动 Workspace 组。 -组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 +Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session,用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 -前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 +当前空白 Session 会显示为一条「New session」行,但不显示数量、时间标签或行菜单;其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行。 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 @@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 - 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。 - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 -- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 -- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持。 +- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量。 - UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences -- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立。 - 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 - 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。 - 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml index 4e2d35175f..6e4ca08c86 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c -2026-07-26-code-dispatch-ui-foundation.zh.md: aeb57b93d781163dd0a4747ac03053c65deda1db +2026-07-26-code-dispatch-ui-foundation.md: 94316e774f231a2f2d5e9bcc8d1a30fd4a2ec733 +2026-07-26-code-dispatch-ui-foundation.zh.md: 2c9ee5b93e20b5c09950e2896a888f90863b8bd0 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md index 4115a1898d..94316e774f 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md @@ -16,7 +16,7 @@ Three changes, one per obstacle: 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript. 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes. -3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service — workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. +3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md index aeb57b93d7..2c9ee5b93e 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md @@ -16,7 +16,7 @@ Status: implemented 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 9f98597809..e7ed85f4c6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 5664960ce94dfac60d89cb363ea62459dd51f59e -2026-07-26-todo-parallel-in-progress.zh.md: f346f89fcae5e8607c5608bbf7541ada0bae4593 +2026-07-26-todo-parallel-in-progress.md: 3d7b80e94762f8ca24b716c42619bf20d9a13608 +2026-07-26-todo-parallel-in-progress.zh.md: 28e32c949de09b5c0722cd1c7e9d91c1542a6cf7 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 5664960ce9..3d7b80e947 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -51,4 +51,4 @@ Two known gaps are deferred. The `summarySuffix` span carries no accessible name ## Consequences -A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. +A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index f346f89fca..28e32c949d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -51,4 +51,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 +现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 781c07f8f3..3148aa5836 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 84e5cdb786944b014fd3a687cfefad6768174006 -2026-07-28-web-terminal-card.zh.md: 58c236bda9c45e13fda0f965f84af24b3c831bb4 +2026-07-28-web-terminal-card.md: ef6da52b4166b61579cf3ef98e760c2f1c42b679 +2026-07-28-web-terminal-card.zh.md: 80c555f227b4e6dd96adcfb666f52e9bb42e2d41 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 84e5cdb786..ef6da52b41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -55,9 +55,9 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.client.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. -`packages/client/ui-tool/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 is named `bash` and turn 60 stays `fx-bash` so one fixture covers both shapes, and turn 60's command is two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 58c236bda9..80c555f227 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -55,9 +55,9 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.client.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 -`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮名为 `bash`、第 60 轮保持 `fx-bash`,于是一份 fixture 覆盖两种形态,而第 60 轮的命令为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 549145aad0..a37a306181 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: d8b0cf32e42af45e16e6f7059c873c384d4a9229 -2026-07-30-web-diff-card.zh.md: 51df03c3f8b79cd8415a15bd30e92e3c152f335e +2026-07-30-web-diff-card.md: 465a6fc9e2fbe61fd1a2e6f11590d01e2553d506 +2026-07-30-web-diff-card.zh.md: d674b1a323f15f52592b51b6188e0b80d1ba977c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index d8b0cf32e4..465a6fc9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -44,9 +44,9 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. -`packages/client/ui-tool/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index 51df03c3f8..d674b1a323 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -44,9 +44,9 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 -`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身约定它不带 diff 行为断言,那由接线套件负责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml index 9e159b5b40..13091086c4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md -2026-07-30-web-read-card-frontend.md: 1d9b6d7fa0c8b5391d175a53507db88a84234b91 -2026-07-30-web-read-card-frontend.zh.md: 2678d50e851e4b3a079f59c7db6ec08ecc5c4aed +2026-07-30-web-read-card-frontend.md: 98e31d4192f7522f9d0e23bce56372a70f8c50b6 +2026-07-30-web-read-card-frontend.zh.md: 294821ec9f1b35f58c399aa77d21b629557cd059 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md index 1d9b6d7fa0..98e31d4192 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -40,9 +40,9 @@ A read row in the Web chat now carries the file content resident, a deliberate d ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. -`packages/client/ui-tool/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. +`packages/client/ui-tool/tests/read-card.client.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md index 2678d50e85..294821ec9f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md @@ -40,9 +40,9 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行 ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 -`packages/client/ui-tool/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 +`packages/client/ui-tool/tests/read-card.client.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index 37debadbe3..03a676161c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md -2026-07-30-web-result-card-frontend.md: c048c4f93f72e4a1da5c6426497e9274d65ac266 -2026-07-30-web-result-card-frontend.zh.md: a7090818dc023f857e01e43773adccfba585cc55 +2026-07-30-web-result-card-frontend.md: 6a00e0820af2c4010554df444a7585fb226294bf +2026-07-30-web-result-card-frontend.zh.md: 727466dbbde1a7d06673713f635e16769eda00e0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index c048c4f93f..6a00e0820a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -36,9 +36,9 @@ The whole-row collapse/expand interaction shared by every resident card (termina ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. -`packages/client/ui-tool/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. +`packages/client/ui-tool/tests/web-card.client.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index a7090818dc..727466dbbd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -36,9 +36,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 -`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-tool/tests/web-card.client.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配约定的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 79bb4d0fb4..a818d7c294 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md -2026-07-30-web-search-card.md: 23c89585770151551526223454e98d18716d1a44 -2026-07-30-web-search-card.zh.md: f92bd9f9231d947d4c70e283b462294c030e3f33 +2026-07-30-web-search-card.md: 930a705fc227d7961e9cb1a51b0b04774a62c6a4 +2026-07-30-web-search-card.zh.md: 83904c8f583af6a0ef3b24bbca2336c457644f6c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 23c8958577..930a705fc2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -54,9 +54,9 @@ Three sites consume the derivation, mirroring the terminal card's placement exac ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-tool/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. +`packages/client/ui-tool/tests/search-card.client.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index f92bd9f923..83904c8f58 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -54,9 +54,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-tool/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 +`packages/client/ui-tool/tests/search-card.client.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 ## Related diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 24219d9ff0..88b9f415d4 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14 -2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7 +2026-08-01-windows-pwsh-default.md: c66e289c24d6024b1df53cd60f25c27d46fafc5a +2026-08-01-windows-pwsh-default.zh.md: b2ad45ec96d546d01436a383ed7d8884b978aa31 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 4e681b3208..c66e289c24 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style. +- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`, and the executor's dependency chain supplies `dsh-pwsh-local`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -32,13 +31,13 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba ## Consequences -- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there. On the Web surface the shell TOOL rows come from the session's preset (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note owns the one-plane mechanism): each shipped preset declares `tool-pwsh` gated by `process.platform !== 'win32'` and its `tool-bash` twin by the inverted expression, so the preset layer exposes exactly one shell tool per host. - Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default. -- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself. +- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched. -- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level shell-tool gates (`tool-bash`/`tool-pwsh`) and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships. +- Keyless: a `dsh --profile --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index a9d600f8a8..b2ad45ec96 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox`、`sandbox-policy`、`fs-sandbox`、`permission`/`ui-permission` 与 `approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`,按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **base patch 在自身行上按平台门控两个 shell 栈**([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`,执行器的依赖链提供 `dsh-pwsh-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -32,13 +31,13 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b ## 后果 -- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`。在 Web 表面,shell 工具行来自会话的预设([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 拥有 one-plane 机制):每个 shipped 预设声明 `tool-pwsh`(以 `process.platform !== 'win32'` 门控)及其孪生行 `tool-bash`(取反表达式),因此预设层每台宿主恰好暴露一个 shell 工具。 - Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。 -- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- POSIX 主机如常挂载 bash 栈;pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付行——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行,并且不改变沙箱、权限、fs 与审批的归属。 -- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app),固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 shell 工具门控(`tool-bash`/`tool-pwsh`)与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch。 +- Keyless:`dsh --profile --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index 484df46a31..d0cb4e3cd9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: 18e94b2e0075bf7099b9b48177de2e274896942a -2026-08-02-web-thinking-tail-scroll.zh.md: 7bafeb0f64f05a41c502aba8f479e391c8b395f0 +2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 +2026-08-02-web-thinking-tail-scroll.zh.md: 34a0a39f88a9511a1c93d0b21b3ac13903848e98 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index 18e94b2e00..b9aa47a01b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 7bafeb0f64..34a0a39f88 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index 49a14093ef..b77b574232 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: f6b118b91d3d2102e11fa7b6d068621e961a7674 -2026-08-03-web-search-source-scroll.zh.md: 58eae594bd18c58f7fb80322f6902abdbba49ba7 +2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374 +2026-08-03-web-search-source-scroll.zh.md: 34ee46c6837cc81740d3f74df1f2c6eae689adf2 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index f6b118b91d..3402f519e1 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -36,7 +36,7 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  • ` with no `[aria-expanded]` and no ` {subOpen && entry.submenu !== undefined && (
    @@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
    diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/browser-plugin.spec.ts rename to packages/client/ui-question/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/node-plugin.spec.ts rename to packages/client/ui-question/tests/node-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/plan-review-panel.spec.tsx b/packages/client/ui-question/tests/plan-review-panel.client.spec.tsx similarity index 99% rename from packages/client/ui-question/tests/plan-review-panel.spec.tsx rename to packages/client/ui-question/tests/plan-review-panel.client.spec.tsx index be22597a04..2abbe85844 100644 --- a/packages/client/ui-question/tests/plan-review-panel.spec.tsx +++ b/packages/client/ui-question/tests/plan-review-panel.client.spec.tsx @@ -9,7 +9,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.client.spec.tsx similarity index 99% rename from packages/client/ui-question/tests/question-composer.spec.tsx rename to packages/client/ui-question/tests/question-composer.client.spec.tsx index 91475f43e4..9f3fce1ab4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.client.spec.tsx @@ -5,7 +5,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index a6400f7c84..3913ac0e9c 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 89e5fcfa0d..29391c3f00 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index f1bd87e9af..060e8d115b 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -1,19 +1,20 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar - foot trigger row + centered 1080x700 modal panel. The trigger reproduces - the former sidebar foot geometry (49px wide row / 36px rail circle); the + foot trigger row + centered 1080x700 modal panel. The trigger uses the + sidebar's 34px compact row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ -/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */ .trigger { flex: none; display: flex; align-items: center; gap: 8px; - width: 100%; - height: 49px; - margin: 8px 0 0; - padding: 0 2px 0 6px; + width: calc(100% + 8px); + height: 34px; + margin: 4px -4px 4px; + padding: 6px 2px 6px 10px; + box-sizing: border-box; border: none; border-radius: 12px; background: transparent; @@ -22,6 +23,7 @@ color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; + line-height: 22px; } .trigger:hover { @@ -32,7 +34,7 @@ .trigger.rail { width: 36px; height: 36px; - margin: 18px 0 10px; + margin: 8px 0 10px; justify-content: center; gap: 0; padding: 0; diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 1c207efc76..3a868dc3b4 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,7 @@ * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: the settings slot declarations plus the ctx.settingsScope Context diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index eb1d9590b9..dde604d5ae 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,6 +1,6 @@ /** State owner for the optional local settings-document action. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** Browser state of the Host-owned settings document. */ diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index c95c9e46d8..48f96bcb94 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,6 +1,6 @@ /** Welcome-notice state, durable when the browser may use Host settings. */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/apply.spec.ts rename to packages/client/ui-settings-general/tests/apply.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/components.spec.tsx rename to packages/client/ui-settings-general/tests/components.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/host.spec.ts rename to packages/client/ui-settings-general/tests/host.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/invariant.spec.ts rename to packages/client/ui-settings-general/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts similarity index 98% rename from packages/client/ui-settings-general/tests/settings-document-store.spec.ts rename to packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 9be3cf3252..b514da85da 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' function response(hasDocument = false): RpcResponse<{ diff --git a/packages/client/ui-settings-general/tests/settings-root.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/settings-root.spec.tsx rename to packages/client/ui-settings-general/tests/settings-root.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/shell.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/shell.spec.ts rename to packages/client/ui-settings-general/tests/shell.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/welcome-notice.spec.tsx rename to packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.client.spec.ts similarity index 99% rename from packages/client/ui-settings-general/tests/welcome-store.spec.ts rename to packages/client/ui-settings-general/tests/welcome-store.client.spec.ts index 04f2608763..bfa5e16999 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts' import { diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index a4535df785..3f8200e137 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-slots" }, - { - "path": "../connection" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 56009fca92..95575dc666 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -46,7 +46,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -58,9 +57,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 84591802c1..241ca86ae9 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, @@ -24,7 +24,7 @@ import { // `$on` and its key face without dragging a build artifact in. The runtime // `remote` injection belongs to whoever calls bindSettingsScope: the // subscription is registered on the caller's own context. -import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is // `Extract`, so the allowlist alone resolves to diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/invariant.spec.ts rename to packages/client/ui-settings/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings/tests/plugin.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/plugin.spec.ts rename to packages/client/ui-settings/tests/plugin.client.spec.ts diff --git a/packages/client/ui-settings/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts similarity index 99% rename from packages/client/ui-settings/tests/settings-scope.spec.ts rename to packages/client/ui-settings/tests/settings-scope.client.spec.ts index 88baeab83b..3aed7c7042 100644 --- a/packages/client/ui-settings/tests/settings-scope.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts' diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 193107b1d2..4fa9f78ab8 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -17,14 +17,11 @@ { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../schema-form" }, { - "path": "../../api/gateway" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../settings/settings" diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 4660ea5814..562396d88f 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 4eb9eeb73f1f8398eb9d16434996840182ba79a9 -README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d +README.md: 9974118f69901de985e012e1b62f95a0bcee64c2 +README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 4eb9eeb73f..9974118f69 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. +New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. @@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available. -- **Group-by supports Workspace only** — Update and Status are not available strategies. +- **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to [ui-workspace](../ui-workspace/README.md), not this shell. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index a9fb927305..11b0aa142c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 +侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 -New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。 +New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 -`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 @@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 - **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 -- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 +- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 73ce0817d7..3f1d00963f 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 67310853a2..17333b5ccc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -167,7 +167,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 8px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; @@ -215,16 +215,20 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; overflow: hidden; } .collapsed .regionArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (49px wide row / 36px rail + trigger row inside owns its own geometry (38px wide row / 36px rail circle) and hover chrome. */ .footArea { flex: none; diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b30e4232e..4da4d14eed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps { export type SidebarRootInjected = { /** * Start a New Session: with a workspace, reuse-or-create its blank session - * and open it; without one, clear the selection into the New Session pure - * view state (the conversation.empty seat). + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. */ startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 3d7ed23aa4..a9706c3e99 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action - // (recent-Workspace targeting; explicit Workspace wins for scoped actions). + // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap similarity index 100% rename from packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap rename to packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/apply.spec.tsx rename to packages/client/ui-sidebar/tests/apply.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/invariant.spec.ts b/packages/client/ui-sidebar/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/invariant.spec.ts rename to packages/client/ui-sidebar/tests/invariant.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx rename to packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts b/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts rename to packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-root.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts similarity index 84% rename from packages/client/ui-sidebar/tests/sidebar-styles.spec.ts rename to packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 63721258c9..c4abce1911 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => { const root = declarations('.root') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') + expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') + expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') expect(declarations('.regionArea')?.get('margin-right')).toBe( 'calc(-1 * var(--dsh-sidebar-inline-padding))', ) + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) }) diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index cc6dd4bb60..e5d11fe0e1 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -51,10 +51,10 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" @@ -65,10 +65,10 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 5511398234..a1e7527292 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -29,9 +29,8 @@ * This browser half also owns the `skill` keyed toolview: a replay-stable * accent row derived only from each logged call/result slice. */ -import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' -// Type-only: pulls the forwarded Host-event face and ctx.remote merge. -import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. +import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-skill/tests/browser-plugin.spec.ts rename to packages/client/ui-skill/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx similarity index 100% rename from packages/client/ui-skill/tests/skill-row.spec.tsx rename to packages/client/ui-skill/tests/skill-row.client.spec.tsx diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index b1d9835eee..8ea4ba311a 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 485052ae5f..c0a1fcd48a 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slash", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/apply.spec.ts rename to packages/client/ui-slash/tests/apply.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-detect.spec.ts b/packages/client/ui-slash/tests/core-detect.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-detect.spec.ts rename to packages/client/ui-slash/tests/core-detect.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-menu.spec.ts b/packages/client/ui-slash/tests/core-menu.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-menu.spec.ts rename to packages/client/ui-slash/tests/core-menu.client.spec.ts diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.client.spec.tsx similarity index 100% rename from packages/client/ui-slash/tests/menu-view.spec.tsx rename to packages/client/ui-slash/tests/menu-view.client.spec.tsx diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/service.spec.ts rename to packages/client/ui-slash/tests/service.client.spec.ts diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 85a8886519..2d045c3d60 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/core.spec.ts rename to packages/client/ui-slots/tests/core.client.spec.ts diff --git a/packages/client/ui-slots/tests/dynamic-keys.spec.ts b/packages/client/ui-slots/tests/dynamic-keys.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/dynamic-keys.spec.ts rename to packages/client/ui-slots/tests/dynamic-keys.client.spec.ts diff --git a/packages/client/ui-slots/tests/invariant.spec.ts b/packages/client/ui-slots/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/invariant.spec.ts rename to packages/client/ui-slots/tests/invariant.client.spec.ts diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-slots/tests/type-chain.spec.tsx rename to packages/client/ui-slots/tests/type-chain.client.spec.tsx diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 75e5b53af7..208023446a 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-subagent/tests/browser-plugin.spec.ts rename to packages/client/ui-subagent/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx similarity index 100% rename from packages/client/ui-subagent/tests/conversation-ui.spec.tsx rename to packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json index c8354f8f6c..4fb6a48f6e 100644 --- a/packages/client/ui-task/package.json +++ b/packages/client/ui-task/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-task", "description": "Session-header background-task list: live registry state mirrored from session/tasks frames", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-task/tests/browser-plugin.spec.ts rename to packages/client/ui-task/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.client.spec.tsx similarity index 100% rename from packages/client/ui-task/tests/task-list-action.spec.tsx rename to packages/client/ui-task/tests/task-list-action.client.spec.tsx diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index f2b8a683be..34d627a2ad 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx similarity index 100% rename from packages/client/ui-theme/tests/appearance-row.spec.tsx rename to packages/client/ui-theme/tests/appearance-row.client.spec.tsx diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/apply.spec.ts rename to packages/client/ui-theme/tests/apply.client.spec.ts diff --git a/packages/client/ui-theme/tests/boot-theme.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/boot-theme.spec.ts rename to packages/client/ui-theme/tests/boot-theme.client.spec.ts diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/host.spec.ts rename to packages/client/ui-theme/tests/host.client.spec.ts diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/invariant.spec.ts rename to packages/client/ui-theme/tests/invariant.client.spec.ts diff --git a/packages/client/ui-theme/tests/scrollbar-styles.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/scrollbar-styles.spec.ts rename to packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/settings-store.spec.ts rename to packages/client/ui-theme/tests/settings-store.client.spec.ts diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/theme.spec.ts rename to packages/client/ui-theme/tests/theme.client.spec.ts diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 6644d1d506..23bcbb3a04 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -48,16 +48,19 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -69,7 +72,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-tool/tests/ask-question-row.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/ask-question-row.spec.tsx rename to packages/client/ui-tool/tests/ask-question-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx similarity index 98% rename from packages/client/ui-tool/tests/assembly-surfaces.spec.tsx rename to packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 80ee97d157..c9e5486da2 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -8,7 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -68,7 +68,7 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx rename to packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index fbdfc4b75e..61a15dd283 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -25,7 +25,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/coverage-tails.spec.tsx rename to packages/client/ui-tool/tests/coverage-tails.client.spec.tsx diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/diff-card.spec.tsx rename to packages/client/ui-tool/tests/diff-card.client.spec.tsx index 1726c81136..6708d3806a 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/read-card.spec.tsx rename to packages/client/ui-tool/tests/read-card.client.spec.tsx index ae719173d5..6351c5c312 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -18,7 +18,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' @@ -26,7 +26,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/search-card.spec.tsx rename to packages/client/ui-tool/tests/search-card.client.spec.tsx index 3ff068b3c9..cc3a1acf72 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -15,7 +15,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -25,7 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/terminal-card.spec.tsx rename to packages/client/ui-tool/tests/terminal-card.client.spec.tsx index dd39b8bc88..ad351dfabe 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/todo-row.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/todo-row.spec.tsx rename to packages/client/ui-tool/tests/todo-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-call-tree.spec.tsx rename to packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-details-render.tsx rename to packages/client/ui-tool/tests/tool-details-render.client.tsx diff --git a/packages/client/ui-tool/tests/tool-row-styles.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts similarity index 100% rename from packages/client/ui-tool/tests/tool-row-styles.spec.ts rename to packages/client/ui-tool/tests/tool-row-styles.client.spec.ts diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-row.spec.tsx rename to packages/client/ui-tool/tests/tool-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx similarity index 98% rename from packages/client/ui-tool/tests/toolview-slot.spec.tsx rename to packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 2ab4ba9b12..4fa5dbeb5d 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -18,7 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId @@ -64,7 +64,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -200,7 +200,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/toolview-type-chain.spec.tsx rename to packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/web-card.spec.tsx rename to packages/client/ui-tool/tests/web-card.client.spec.tsx index 04535a0954..8a3efd2627 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -16,7 +16,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -25,7 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 17516295fb..7183ad66d2 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 725dc11d5f..54aa132b09 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/cell.spec.tsx rename to packages/client/ui-trajectory/tests/cell.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts similarity index 94% rename from packages/client/ui-trajectory/tests/client-bundle.spec.ts rename to packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index edb2e3072b..2efedc8d3e 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' import { ConversationEventRegistry, ConversationViewRegistry, SlotsService, @@ -82,9 +83,11 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle). + // handle and the Host-facing settings/remote seams). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/conversation-definitions.spec.ts rename to packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/export-log.spec.ts rename to packages/client/ui-trajectory/tests/export-log.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/layout.spec.tsx rename to packages/client/ui-trajectory/tests/layout.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/snapshot-builder.spec.ts rename to packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/table.spec.tsx rename to packages/client/ui-trajectory/tests/table.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/toolbar.spec.tsx rename to packages/client/ui-trajectory/tests/toolbar.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/views.spec.tsx rename to packages/client/ui-trajectory/tests/views.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/virtual-rows.spec.ts b/packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/virtual-rows.spec.ts rename to packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 001d2ae058..764363578b 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx similarity index 100% rename from packages/client/ui-workflow-run/tests/workflow-run.spec.tsx rename to packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1a1dd057f0..7a50937c41 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 -README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538 +README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc +README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1ec07bd41e..9d7d4d77cc 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,9 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. + +Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8edd0fed6d..ca35d7cd2e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,9 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 + +折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index f19c44ea57..77b5ffded2 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6c6c44c2c7..a160f2ffd7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,9 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Section header: 36px, "Workspaces/Sessions" label + group-by / - new-workspace buttons; the right-anchored new-workspace button is the - row's rail survivor. */ +/* Section header: title, an inline search control, and the two trailing + actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { flex: none; display: flex; @@ -48,7 +47,7 @@ justify-content: flex-end; gap: 4px; height: 36px; - padding-left: 12px; + padding-left: 4px; margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; @@ -56,71 +55,118 @@ color: var(--dsw-alias-label-tertiary); } +.root:not(.rail) .sectionHeader { + margin-top: 2px; + margin-right: -4px; +} + .sectionLabel { - flex: 1; + flex: none; + max-width: 45%; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off - corners); rail state renders it as the - region's search control. Upstream binds a dedicated design-system variable - (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component - token pinned to the static scale mirrors it. */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; +} + +.searchSlot { + flex: 1; + max-width: 28px; + min-width: 0; + display: flex; + align-items: center; + margin-left: auto; + padding-left: 0; + box-sizing: border-box; + transition: + max-width 180ms var(--ds-ease-in-out), + padding-left 180ms var(--ds-ease-in-out); +} + +.searchSlotExpanded { + max-width: 100%; + padding-left: 0; +} + +.headerActions { flex: none; display: flex; align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); + gap: 4px; + max-width: 60px; + opacity: 1; overflow: hidden; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +.headerActionsHidden { + max-width: 0; + opacity: 0; + transform: translateX(4px); + visibility: hidden; + pointer-events: none; + transition-delay: 0s, 0s, 0s, 180ms; } -/* The capsule's leading icon: decorative while wide (pointer-events off so - clicks reach the input), the hit target in rail state. */ -.searchButton { +/* Inline search always fills the room between the title and trailing actions; + it grows farther right when the action cluster collapses. */ +.search { flex: none; - display: inline-flex; + display: flex; align-items: center; - justify-content: center; + gap: 0; + width: 100%; + height: 28px; + margin: 0; + padding: 0; + box-sizing: border-box; border: none; border-radius: 50%; - padding: 0; background: transparent; - pointer-events: none; - color: inherit; + cursor: text; + color: var(--dsw-alias-label-secondary); + overflow: hidden; + transition: + width 180ms var(--ds-ease-in-out), + padding 180ms var(--ds-ease-in-out), + border-color 180ms var(--ds-ease-in-out), + background-color 180ms var(--ds-ease-in-out); } -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; +.searchExpanded { + width: calc(100% + 4px); + height: 30px; + margin-inline: -2px; + padding: 0 4px 0 0; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-caption); } -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { +.searchButton { flex: none; display: inline-flex; align-items: center; @@ -132,9 +178,66 @@ padding: 0; background: transparent; cursor: pointer; + color: inherit; +} + +.searchExpanded .searchButton { + width: 28px; + height: 30px; +} + +.searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchExpanded .searchButton:hover { + background: transparent; +} + +.searchInput { + flex: 1; + width: 0; + min-width: 0; + border: none; + outline: none; + background: transparent; + opacity: 0; + pointer-events: none; + font-size: 13px; + line-height: 18px; + color: var(--dsw-alias-label-primary); + transition: opacity 120ms var(--ds-ease-in-out); +} + +.searchExpanded .searchInput { + margin-left: -2px; + opacity: 1; + pointer-events: auto; +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -144,6 +247,10 @@ margin-bottom: 12px; } +.rail .headerActions { + max-width: none; +} + .rail .iconButton { width: 36px; height: 36px; @@ -151,6 +258,7 @@ } .rail .search { + width: 36px; height: 36px; padding: 0; margin: 0 0 12px; @@ -162,8 +270,6 @@ .rail .searchButton { width: 36px; height: 36px; - pointer-events: auto; - cursor: pointer; color: var(--dsw-alias-label-primary); } @@ -177,12 +283,18 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset)); - overflow: hidden; + padding-left: 4px; + /* The list remains the scroll clip. This seat stays visible so the + absolutely positioned first-boundary marker can occupy the header gap. */ + overflow: visible; } .rail .listArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Relative for the bottom fade overlay. */ @@ -194,14 +306,14 @@ position: relative; } -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, +/* Bottom fade: compact overlay pinned to the visible bottom, transparent -> sidebar fill so it tracks the theme. */ .fade { position: absolute; left: 0; right: var(--dsh-session-list-edge-inset); bottom: 0; - height: 72px; + height: 24px; background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); pointer-events: none; } @@ -223,15 +335,17 @@ flex: 1; min-height: 0; overflow-y: auto; + margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); + padding-left: 4px; padding-right: calc( var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - /* Clears the 72px bottom fade overlay: at scroll end the last row sits + /* Clears the compact bottom fade overlay: at scroll end the last row sits above the gradient instead of under it. */ - padding-bottom: 48px; + padding-bottom: 16px; scrollbar-gutter: stable; } @@ -254,10 +368,84 @@ } /* One workspace section: header row + a compact expanded session run. */ +.groupSection { + position: relative; +} + .groupSection + .groupSection { margin-top: 4px; } +.listTopDropIndicator, +.workspaceDropBefore::before, +.workspaceDropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 0; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +/* The first insertion boundary keeps the same -8px coordinate as every + Workspace boundary, but lives outside the scrolling clip. */ +.listTopDropIndicator { + top: -8px; + left: 0; + right: var(--dsh-session-list-edge-inset); +} + +.listTopDropActive > .workspaceDropBefore:first-child::before { + display: none; +} + +.workspaceDropBefore::before { + top: -8px; +} + +.workspaceDropAfter::after { + bottom: -8px; +} + +.sessionOverflowButton { + width: 100%; + height: 28px; + border: none; + border-radius: 8px; + padding: 0 12px 0 28px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.groupSection > .sessionOverflowButton { + margin-top: 0; +} + +.sessionOverflowButton:hover { + background: transparent; + color: var(--dsw-alias-label-secondary); +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); @@ -305,4 +493,12 @@ .wide { animation: none; } + + .search, + .sectionLabel, + .searchSlot, + .searchInput, + .headerActions { + transition: none; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..e9d6ed192f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,6 +1,6 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + add + * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two * region icons (search / add workspace), each requesting shell expansion @@ -16,12 +16,13 @@ import { IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionSearchResultItem, WorkspaceId, WorkspaceView, + SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode } from './tree.ts' +import type { SessionNode, SessionOrderBy } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' +import { FLAT_SESSION_ORDER_KEY } from './stores.ts' import { WorkspacePickFlow } from './WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' @@ -34,6 +35,8 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 +/** Session rows visible per Workspace before the local overflow control. */ +const COLLAPSED_SESSION_LIMIT = 5 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -46,15 +49,106 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expansion arrays. */ +/** Immutable membership toggle for the local expand-all array. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu({ groupBy, onPick, t }: { +/** + * Accept the native drag at document level while a row drag is active: row + * hover still owns the insertion marker, and releasing outside the list must + * not be rendered as a rejected drop before dragend commits that last marker. + */ +function useNativeDragAcceptance(active: boolean): void { + useEffect(() => { + if (!active) return + const acceptDrag = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' + } + const acceptDrop = (event: DragEvent): void => { event.preventDefault() } + document.addEventListener('dragover', acceptDrag) + document.addEventListener('drop', acceptDrop) + return () => { + document.removeEventListener('dragover', acceptDrag) + document.removeEventListener('drop', acceptDrop) + } + }, [active]) +} + +/** Reconcile a stored view order with the Workspace's current session account. */ +function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] { + if (stored === undefined) return [...sessionIds] + const byId = new Map(sessionIds.map(id => [id as string, id])) + const ordered: SessionId[] = [] + const included = new Set() + for (const key of stored) { + const id = byId.get(key) + if (id === undefined || included.has(key)) continue + ordered.push(id) + included.add(key) + } + for (const id of sessionIds) { + if (included.has(id)) continue + ordered.push(id) + } + return ordered +} + +/** Newest update first with stable Session identity as the tie-break. */ +function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number { + const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY + const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY + if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt + return a < b ? -1 : 1 +} + +/** Reconcile one editable order account and apply its activity-promotion policy. */ +function nextSessionOrderAccount({ + sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency, +}: { + sessionIds: readonly SessionId[] + previousOrder: readonly string[] | undefined + previousUpdatedAt: Readonly> + list: SessionListState + orderBy: SessionOrderBy + sortByRecency: boolean +}): { order: SessionId[]; updatedAt: Record; changed: boolean } { + let order = reconciledSessionOrder(sessionIds, previousOrder) + if (sortByRecency) { + order.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else if (orderBy === 'updated') { + const promoted = sessionIds + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + order = [...promoted, ...order.filter(id => !promotedIds.has(id))] + } + } + const updatedAt: Record = {} + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) updatedAt[id] = session.updatedAt + } + const orderChanged = previousOrder === undefined + || order.length !== previousOrder.length + || order.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp) + return { order, updatedAt, changed: orderChanged || timestampsChanged } +} + +/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' - onPick: (mode: 'workspace' | 'flat') => void + orderBy: SessionOrderBy + onGroupPick: (mode: 'workspace' | 'flat') => void + onOrderPick: (mode: SessionOrderBy) => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) @@ -66,23 +160,28 @@ function GroupByMenu({ groupBy, onPick, t }: { { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { id: 'workspace', label: t('groupBy.workspace') }, { id: 'flat', label: t('groupBy.flat') }, + { type: 'separator' as const, id: 'order-by-separator' }, + { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, + { id: 'manual', label: t('orderBy.manual') }, + { id: 'updated', label: t('orderBy.updated') }, ]} - selectedId={groupBy} + selectedIds={[groupBy, orderBy]} onSelect={(id) => { - /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ - if (id === 'workspace' || id === 'flat') onPick(id) + if (id === 'workspace' || id === 'flat') onGroupPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" + dense // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal anchor={( - + + )} +
    + ) + })}
    ) } -/** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +/** The flat "In one list" body: every session is one draggable top-level row. */ +function FlatList({ + useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, + orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, +}: Pick< + SessionTreeProps, + | 'useSessions' + | 'open' + | 'forkSession' + | 'onSessionRename' + | 'onSessionArchive' + | 'archivedSessionIds' + | 'orderBy' + | 'recentSessionOrder' + | 'recentSessionUpdatedAt' + | 'syncRecentSessions' + | 'setRecentSessionOrder' + | 't' >) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) + const baseRows = useMemo( + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], + ) + const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows]) + const previousOrderBy = useRef(orderBy) + useEffect(() => { + if (list.phase !== 'ready') return + const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY] + const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {} + const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated' + previousOrderBy.current = orderBy + const next = nextSessionOrderAccount({ + sessionIds, + previousOrder, + previousUpdatedAt, + list, + orderBy, + sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated), + }) + if (next.changed) { + syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt) + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions]) + const rows = useMemo(() => { + const byId = new Map(baseRows.map(row => [row.id, row])) + return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .flatMap((id) => { + const row = byId.get(id) + return row === undefined ? [] : [row] + }) + }, [baseRows, recentSessionOrder, sessionIds]) + const [drag, setDrag] = useState(null) + const dropCommitted = useRef(false) + useNativeDragAcceptance(drag !== null) + const commitDrag = (activeDrag: DragState, over: NonNullable): void => { + if (dropCommitted.current) return + dropCommitted.current = true + setDrag(null) + const targetIndex = rows.findIndex(row => row.id === over.id) + if (targetIndex === -1) return + const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id + if (anchor === activeDrag.sessionId) return + const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId) + const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId) + const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) + nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string)) + } const now = Date.now() return (
    @@ -244,19 +620,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr {rows.length === 0 && (
    {t('empty.none')}
    )} - {rows.map(node => ( - - ))} + {rows.map((node) => { + const active = drag !== null + return ( + { + dropCommitted.current = false + setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null }) + }, + active, + marker: active && drag.over?.id === node.id ? drag.over.half : null, + hover: (half) => { + setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } }) + }, + drop: (half) => { + if (drag !== null) commitDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over) + else setDrag(null) + dropCommitted.current = false + }, + }} + t={t} + /> + ) + })}
    @@ -352,6 +751,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + insertWorkspaceBefore, archiveSession, insertSessionBefore, createWorkspace, @@ -362,14 +762,28 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) + const orderBy = useStore(s => s.orderBy) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) + useEffect(() => { + if (workspacePhase !== 'ready') return + actions.retainWorkspaceKeys([ + UNGROUPED_KEY, + FLAT_SESSION_ORDER_KEY, + ...workspaces.map(workspace => workspace.workspaceId as string), + ]) + }, [actions.retainWorkspaceKeys, workspacePhase, workspaces]) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -377,6 +791,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -397,6 +812,23 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (!wide || !searchExpanded || searchOnExpand) return + searchInput.current?.focus({ preventScroll: true }) + }, [wide, searchExpanded, searchOnExpand]) + + useEffect(() => { + if (!wide || !searchExpanded) return + const onClick = (event: MouseEvent): void => { + if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return + searchInput.current?.blur() + if (normalizedQuery !== '') return + setSearchExpanded(false) + } + document.addEventListener('click', onClick) + return () => { document.removeEventListener('click', onClick) } + }, [normalizedQuery, wide, searchExpanded]) + useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) @@ -544,29 +976,96 @@ export function WorkspaceBrowser({
    {wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} - {/* Adding is the button's one action, so a composition with no - picking affordance has nothing to offer here: the region hides the - button rather than leaving a dead one in the header. */} - {directoryFlowAvailable && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} +
    +
    )} +
    + {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} + {/* Adding is the button's one action, so a composition with no + picking affordance has nothing to offer here: the region hides the + button rather than leaving a dead one in the header. */} + {directoryFlowAvailable && ( + + + + )} +
    {/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
    { if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
    + - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
    +
    } {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} @@ -644,7 +1124,13 @@ export function WorkspaceBrowser({ ) : ( @@ -654,10 +1140,18 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceExpansion={workspaceExpansion} + setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} + insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..8027a3623a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -91,9 +91,9 @@ export type DirectoryPickingHooks = { */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** - * Start a New Session in a Workspace: reuse-or-create its blank session - * and open it; with no workspace, clear the selection into the New Session - * pure view state (the conversation.empty seat). + * Start a New Session in a Workspace: reuse-or-create its blank session and + * open it; without an explicit workspace, inherit the current Session + * Workspace, then the recent Workspace, or clear into the New Session view. */ startSession: (workspaceId?: WorkspaceId) => void /** Open a real Session. */ @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..6b14243ecf 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void { const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ - // Explicit group actions keep their target; unscoped New Session rides - // the runtime's shared action (recent-Workspace projection inside). + // Explicit group actions keep their target; unscoped New Session inherits + // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, searchSessions, @@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { + await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + }, archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c08fd931de 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -10,14 +10,20 @@ export const zh = { 'session.new': '新会话', 'section.workspaces': '工作区', 'section.sessions': '会话', + 'viewOptions.label': '视图选项', 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', + 'orderBy.label': '排序方式', + 'orderBy.manual': '手动排序', + 'orderBy.updated': '最近更新', + 'sessions.expand': '展开其余 {n} 个会话', + 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', - 'search.placeholder': '搜索名称、关键词…', + 'search.placeholder': '搜索会话…', 'search.clear': '清除搜索', 'search.results.aria': '搜索结果', 'search.pending': '正在搜索会话历史…', @@ -73,14 +79,20 @@ export const en = { 'session.new': 'New Session', 'section.workspaces': 'Workspaces', 'section.sessions': 'Sessions', + 'viewOptions.label': 'View options', 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', + 'orderBy.label': 'Order by', + 'orderBy.manual': 'Manual', + 'orderBy.updated': 'Last updated', + 'sessions.expand': 'Show {n} more sessions', + 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', - 'search.placeholder': 'Search name, keywords...', + 'search.placeholder': 'Search sessions...', 'search.clear': 'Clear search', 'search.results.aria': 'Search results', 'search.pending': 'Searching session history…', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..5dc899af45 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -21,7 +21,7 @@ } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultRow { @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -45,7 +45,7 @@ } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,21 +82,21 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } -/* Two-line row: the leading slot (folder/chevron), title, and trailing - actions all top-align on the 20px first text line (figma cell) — content - is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ +/* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 54px; - align-items: flex-start; - padding-top: 6px; - padding-bottom: 6px; + height: 34px; + align-items: center; box-sizing: border-box; } @@ -99,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ @@ -110,6 +117,10 @@ margin: 0 6px 0 4px; } +.flatSessionRowWithoutStatus .title { + margin-left: 0; +} + @keyframes row-in { from { opacity: 0; } } @@ -133,11 +144,11 @@ white-space: nowrap; } - .folderActive { color: var(--dsw-alias-state-business-primary); } + /* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } @@ -233,14 +244,46 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group session rows): 2px accent above or - below the hovered row, drawn with box-shadow so no layout shift. */ -.sessionRow.dropBefore { - box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +/* Session drag insert marker: a leading chevron and 2px rule between rows, + absolutely positioned so it neither resembles a row border nor changes layout. */ +.sessionRow.dropBefore, +.sessionRow.dropAfter { + position: relative; } -.sessionRow.dropAfter { - box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +.sessionRow.dropBefore::before, +.sessionRow.dropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 4px; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +.sessionRow.dropBefore::before { + top: -7px; +} + +.sessionRow.dropAfter::after { + bottom: -7px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..481e0f0e47 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -67,29 +67,60 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { } /** - * Project (workspace) header row: 54px, folder + title + session count; + * Row drag wiring supplied by the tree owner. `drop` reports the half of the + * row where the pointer released so the owner can resolve an insert anchor. + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A compatible row drag is in flight. */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a compatible drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */ +interface WorkspaceRowDragProps { + start: () => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +/** + * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real * Workspace shows its hover card (the ungrouped bucket has none). * `containsCurrent` arrives on the node (derivation fact, no renderer scan). * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. + * @param props.drag - optional workspace-row drag wiring. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { group: GroupNode onToggle: () => void onCreate: () => void /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined + /** Present only for real Workspace rows in the grouped view. */ + drag?: WorkspaceRowDragProps | undefined t: RowTranslate }) { const row = group // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = group.expanded && group.containsCurrent - const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount }) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -101,6 +132,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { role="treeitem" aria-expanded={row.expanded} onClick={onToggle} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', row.key) + drag.start() + }} + onDragEnd={drag?.end} > {row.expanded ? : } @@ -110,7 +150,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( @@ -220,6 +259,18 @@ function sessionStatuses( return [{ state: 'done', label: t('status.idle') }] } +/** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */ +function SessionStatusDots({ statuses }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) { + return ( + <> + + {statuses.map(status => ( + {status.label} + ))} + + ) +} + /** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const statuses = sessionStatuses(node, t) @@ -239,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; ) } -/** - * Session-row drag wiring supplied by the group owner (workspace groups only). - * `drop` reports the half of the row the pointer released on: 'before' - * inserts above this row, 'after' below it (the owner resolves the anchor). - */ -export interface RowDragProps { - /** Start dragging this row. */ - start: () => void - /** A drag from the same group is in flight (rows show insert markers). */ - active: boolean - /** Current marker on this row: insert line above, below, or none. */ - marker: 'before' | 'after' | null - /** Report the hovered half while a same-group drag passes over this row. */ - hover: (half: 'before' | 'after') => void - drop: (half: 'before' | 'after') => void - end: () => void -} - /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an @@ -287,30 +320,21 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {(primaryStatus.state !== 'done' || result.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - + )} {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } -/** Pointer-position half of a row (insert line above or below). */ -function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { - const rect = e.currentTarget.getBoundingClientRect() - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' -} - /** * One top-level 34px session row: status dot (pending user interaction outranks * own or descendant activity), title, relative time, and the row actions menu. @@ -322,10 +346,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onFork - fork a session at its last completed turn. * @param props.onArchive - archive a session by id. * @param props.drag - optional draggable-row wiring. + * @param props.flat - omit the empty status slot in the hierarchy-free flat list. * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { node: SessionNode currentId: string | undefined now: number @@ -338,6 +363,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined + /** The row is rendered without a parent Workspace header. */ + flat?: boolean | undefined t: RowTranslate }) { const row = node @@ -345,6 +372,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const selected = node.id === currentId const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] + const showStatus = primaryStatus.state !== 'done' || row.completed const [menuOpen, setMenuOpen] = useState(false) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no @@ -360,6 +388,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
    { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) drag.start() }} onDragEnd={drag?.end} @@ -392,16 +422,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork {/* Pending interaction and own or descendant activity outrank the finished-but-unviewed reminder, which returns after activity stops and is cleared by opening the session. */} - - {(primaryStatus.state !== 'done' || row.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - - )} - + {(!flat || showStatus) && ( + + {showStatus && } + + )} {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..4df6fd6fc3 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -7,11 +7,25 @@ */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +/** Browser-local order account for the hierarchy-free flat Session list. */ +export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__' + /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' +/** Session order: user-arranged only, or user-arranged plus activity promotion. */ +export type WorkspaceOrderBy = 'manual' | 'updated' -/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy } +/** Workspace browser viewing state persisted across surface remounts and reloads. */ +type WorkspaceViewState = { + groupBy: WorkspaceGroupBy + orderBy: WorkspaceOrderBy + /** Explicit zero-or-five-session state keyed by Workspace group identity. */ + workspaceExpansion: Record + /** Shared editable order per Workspace group plus the browser-local flat-list account. */ + recentSessionOrder: Record + /** Last observed update timestamps per order account for one-time promotion events. */ + recentSessionUpdatedAt: Record> +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -19,6 +33,16 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy } */ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void + setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void + setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -27,10 +51,37 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), - persist: 'dsh.workspace.view', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, + setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, + retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => { + const retained = new Set(workspaceKeys) + d.workspaceExpansion = Object.fromEntries( + Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)), + ) + d.recentSessionOrder = Object.fromEntries( + Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)), + ) + d.recentSessionUpdatedAt = Object.fromEntries( + Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)), + ) + }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..e9cbf8ed71 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -32,6 +32,9 @@ export interface SessionNode { updatedAt: number } +/** Session order selected by the Workspace browser. */ +export type SessionOrderBy = 'manual' | 'updated' + /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ @@ -75,6 +78,8 @@ export interface SearchResultSet { /** Viewing state consumed by the derivation. */ export interface TreeView { expandedProjects: readonly string[] + /** Browser-local order for Sessions without a backing Workspace account. */ + ungroupedOrder?: readonly string[] } interface Group { @@ -136,21 +141,41 @@ function buildGroup( order: 'account' | 'recency', ): Group { const sessions = [...members] - // Workspace order is workspace.sessionIds; only Ungrouped lacks an account - // order and therefore falls back to recency. + // Real Workspace order comes from sessionIds. Ungrouped falls back to + // recency until the browser supplies its persisted local order. if (order === 'recency') sessions.sort(byRecency) return { key, workspaceId, cwd, createdAt, label, sessions } } +/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */ +function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] { + const byId = new Map(members.map(session => [session.id as string, session])) + const included = new Set() + const ordered: SessionSummary[] = [] + for (const key of stored) { + const session = byId.get(key) + if (session === undefined || included.has(key)) continue + ordered.push(session) + included.add(key) + } + for (const session of [...members].sort(byRecency)) { + if (included.has(session.id)) continue + ordered.push(session) + } + return ordered +} + /** * Group Sessions by Host Workspace: one group per entity in stable Host * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. + * outside every Workspace trail in the browser-local Ungrouped order, which + * falls back to recency before that order is initialized. */ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + ungroupedOrder: readonly string[] | undefined, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -173,7 +198,15 @@ function groupByWorkspace( .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) + groups.push(buildGroup( + UNGROUPED_KEY, + undefined, + undefined, + undefined, + UNGROUPED_LABEL, + ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder), + ungroupedOrder === undefined ? 'recency' : 'account', + )) } return groups } @@ -197,8 +230,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -222,7 +255,7 @@ export function deriveGroups( : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces, archived)) { + for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -248,7 +281,10 @@ export function deriveGroups( * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { +export function deriveFlat( + list: SessionListState, + archivedSessionIds: readonly SessionId[], +): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) const rows: SessionSummary[] = [] diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/apply.spec.ts rename to packages/client/ui-workspace/tests/apply.client.spec.ts diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts similarity index 53% rename from packages/client/ui-workspace/tests/browser-styles.spec.ts rename to packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 4165971bff..d66baef917 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') +const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8') /** * Declarations of one selector rule, keyed by property with whitespace collapsed. @@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m * @param selector - one exact selector, including a leading dot for local classes. * @returns the rule's declarations, or undefined when no such rule exists. */ -function declarations(selector: string): Map | undefined { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') +function declarationsFrom(source: string, selector: string): Map | undefined { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ') + const found = new Map() for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue - const found = new Map() for (const part of body.split(';')) { const colon = part.indexOf(':') if (colon === -1) continue found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' ')) } - return found } - return undefined + return found.size === 0 ? undefined : found } +const declarations = (selector: string): Map | undefined => declarationsFrom(css, selector) +const rowDeclarations = (selector: string): Map | undefined => declarationsFrom(rowsCss, selector) + describe('WorkspaceBrowser.module.css list', () => { const root = declarations('.root') const listArea = declarations('.listArea') @@ -45,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => { expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') + expect(listArea?.get('margin-left')).toBe('-4px') + expect(listArea?.get('padding-left')).toBe('4px') expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)') expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)') + expect(list?.get('margin-left')).toBe('-4px') + expect(list?.get('padding-left')).toBe('4px') expect(list?.get('padding-right')).toBe([ 'calc(', 'var(--dsh-session-list-edge-inset)', @@ -68,4 +75,36 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px') expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + + it('draws drag targets as a leading chevron joined to the insertion line', () => { + const listTopMarker = declarations('.listTopDropIndicator') + const workspaceMarker = declarations('.workspaceDropBefore::before') + const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before') + expect(listTopMarker?.get('top')).toBe('-8px') + expect(listTopMarker?.get('left')).toBe('0') + expect(workspaceMarker?.get('left')).toBe('0') + expect(sessionMarker?.get('left')).toBe('0') + for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) { + expect(marker?.get('height')).toBe('12px') + expect(marker?.get('background')).not.toContain('radial-gradient') + expect(marker?.get('background')).toContain('55deg') + expect(marker?.get('background')).toContain('125deg') + expect(marker?.get('background')).toContain('calc(50% - 1px) calc(50% + 1px)') + expect(marker?.get('background')).toContain('0 0 / 5px 7px') + expect(marker?.get('background')).toContain('0 5px / 5px 7px') + expect(marker?.get('background')).toContain('4px 5px / calc(100% - 4px) 2px') + } + }) + + it('keeps the compact fade, overflow control, search field, and row heights', () => { + expect(declarations('.fade')?.get('height')).toBe('24px') + expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') + expect(declarations('.searchExpanded')?.get('height')).toBe('30px') + expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px') + expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px') + expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0') + expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px') + expect(rowDeclarations('.sessionRow.selected')?.get('background')) + .toBe('var(--dsw-alias-interactive-bg-hover)') + }) }) diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/invariant.spec.ts rename to packages/client/ui-workspace/tests/invariant.client.spec.ts diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/rename-assembly.spec.tsx rename to packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx similarity index 96% rename from packages/client/ui-workspace/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.client.spec.tsx index 7e0971cf72..c7a153ff5f 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } } -const dataTransfer = { effectAllowed: '', dropEffect: '' } +const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { @@ -57,6 +57,21 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): } describe('workspace browser rows', () => { + it('omits only an empty leading status slot in the hierarchy-free flat list', () => { + const idle: SessionNode = { + id: sid('flat'), title: 'Flat Session', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, + } + const view = render() + const title = screen.getByText('Flat Session') + expect(title.previousElementSibling).toBeNull() + + view.rerender() + expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() + }) + it('renders a selected content-search row and opens only its session', () => { const onOpen = vi.fn() const result: SearchResultNode = { @@ -105,7 +120,6 @@ describe('workspace browser rows', () => { } render() - expect(screen.getByText('1 个会话')).toBeTruthy() expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' })) expect(onCreate).toHaveBeenCalledOnce() diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts similarity index 89% rename from packages/client/ui-workspace/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.client.spec.ts index 5a6d145e36..3ad1468af5 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -11,7 +11,8 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, blank: false, + updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), @@ -23,8 +24,9 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const view = (expandedProjects: readonly string[] = []) => ({ +const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({ expandedProjects, + ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }), }) const noArchive: readonly SessionId[] = [] const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) @@ -53,6 +55,19 @@ describe('deriveGroups', () => { expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) + it('applies stored Ungrouped order and appends new loose Sessions by recency', () => { + const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4)) + const groups = deriveGroups( + sessions, + [], + noArchive, + view([UNGROUPED_KEY], ['two', 'stale', 'two']), + ) + expect(groups[0]!.sessions.map(session => session.id)).toEqual([ + sid('two'), sid('new'), sid('one'), + ]) + }) + it('shows only the current blank session in its Workspace count and tree', () => { const currentBlank = { ...summary('current-blank', 5), blank: true } const staleBlank = { ...summary('stale-blank', 4), blank: true } @@ -377,11 +392,38 @@ describe('deriveSearchResults', () => { }) describe('createWorkspaceViewStore', () => { - it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => { const store = createWorkspaceViewStore().create() expect(store.getSnapshot().groupBy).toBe('workspace') + expect(store.getSnapshot().orderBy).toBe('manual') store.actions.setGroupBy('flat') + store.actions.setOrderBy('updated') + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 }) + store.actions.setRecentSessionOrder('alpha', ['one', 'two']) expect(store.getSnapshot().groupBy).toBe('flat') + expect(store.getSnapshot()).toMatchObject({ + orderBy: 'updated', + workspaceExpansion: { alpha: true }, + recentSessionOrder: { alpha: ['one', 'two'] }, + recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } }, + }) + }) + + it('removes view state outside the retained Workspace key set', () => { + const store = createWorkspaceViewStore().create() + store.actions.setWorkspaceExpanded('', true) + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.setWorkspaceExpanded('deleted', true) + store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 }) + store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 }) + + store.actions.retainWorkspaceKeys(['', 'alpha']) + + const snapshot = store.getSnapshot() + expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true }) + expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] }) + expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx similarity index 66% rename from packages/client/ui-workspace/tests/workspace-browser.spec.tsx rename to packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 918a3f6c0e..c39e7c37c4 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -8,7 +8,8 @@ import type { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' -import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' +import { UNGROUPED_KEY } from '../src/client/tree.ts' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' @@ -53,6 +54,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): fireEvent(row, event) } +function dragData(): Pick { + return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() } +} + function mount(overrides: Partial = {}) { const store = createWorkspaceViewStore().create() const props: WorkspaceBrowserProps = { @@ -71,6 +76,7 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + insertWorkspaceBefore: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -89,6 +95,28 @@ function rerender(b: ReturnType, overrides: Partial { + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { + const pending = { + ...workspaceState([]), + phase: 'pending' as const, + state: 'loading' as const, + baselinesReady: false, + } + const b = mount({ useWorkspaces: hook(pending) }) + act(() => { + b.store.actions.setWorkspaceExpanded('deleted', true) + b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 }) + }) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true }) + + rerender(b, { useWorkspaces: hook(workspaceState([])) }) + await waitFor(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({}) + expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] }) + expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} }) + }) + }) + it('renders the grouped tree by default and switches to the flat list via Group by', () => { const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) const b = mount({ @@ -100,8 +128,14 @@ describe('WorkspaceBrowser', () => { // Sessions hidden while their group is folded. expect(screen.queryByText('alpha-s')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label + expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + '按工作区', '单列表', '手动排序', '最近更新', + ]) + expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) // Store-driven flip: title changes, rows flatten newest-first, headers gone. expect(b.store.getSnapshot().groupBy).toBe('flat') @@ -111,18 +145,73 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('beta-s')).toBeTruthy() // Back to workspace grouping through the same menu. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false) fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' })) expect(b.store.getSnapshot().groupBy).toBe('workspace') expect(screen.getByText('工作区')).toBeTruthy() // Escape closes the menu without picking. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() expect(b.store.getSnapshot().groupBy).toBe('workspace') }) + it('persists flat-list drag order locally and applies Last updated within that account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const workspaces = workspaceState([ + workspace('alpha', ['one']), + workspace('beta', ['two']), + ]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaces), + insertSessionBefore, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + three.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + b.view.unmount() + + const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) }) + expect(restored.store.getSnapshot().groupBy).toBe('flat') + expect(restored.store.getSnapshot().orderBy).toBe('manual') + expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('expands a group on click and opens a session row', () => { const open = vi.fn() mount({ @@ -138,6 +227,93 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-7')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expect(screen.getByText('session-6')).toBeTruthy() + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false }) + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + + it('shares one editable order across modes and promotes only while Last updated is active', async () => { + const initial = sessionState([summary('one', 3), summary('two', 2)]) + const b = mount({ + useSessions: hook(initial), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + const rows = screen.getAllByRole('treeitem').slice(1) + expect(rows[0]?.textContent).toContain('one') + expect(rows[1]?.textContent).toContain('two') + }) + + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // User activity updates the timestamp baseline in Manual mode without + // changing the shared visual order. + const updated = sessionState([summary('one', 4), summary('two', 2)]) + rerender(b, { useSessions: hook(updated) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 }) + }) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // Entering Last updated performs one complete recency sort. + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one') + }) + + // A later user activity timestamp promotes that Session once while the + // mode remains active. + const promoted = sessionState([summary('one', 4), summary('two', 5)]) + rerender(b, { useSessions: hook(promoted) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + + b.view.unmount() + const restored = mount({ + useSessions: hook(promoted), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { const archiveSession = vi.fn(async () => {}) const b = mount({ @@ -150,11 +326,10 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) - // The archive-set echo hides the row in grouped mode (count included) and flat mode. + // The archive-set echo hides the row in grouped and flat modes. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) expect(screen.queryByText('gone-s')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) expect(screen.getByText('kept-s')).toBeTruthy() expect(screen.queryByText('gone-s')).toBeNull() @@ -195,16 +370,20 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true') }) - it('auto-expands the selected session group and starts a session from the group +', () => { + it('expands the target group before starting a session from its +', () => { const startSession = vi.fn() - mount({ - useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + const b = mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), startSession, }) - // The current-group effect expanded the owning group without a click. - expect(screen.getByText('alpha-s')).toBeTruthy() + startSession.mockImplementation(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + }) + expect(screen.queryByText('alpha-s')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' })) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.getByText('alpha-s')).toBeTruthy() expect(startSession).toHaveBeenCalledWith(wid('alpha')) }) @@ -253,7 +432,6 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('新会话')).toBeTruthy() expect(screen.queryByText('alpha-blank')).toBeNull() expect(screen.queryByText('beta-blank')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) }) expect(screen.getAllByText('新会话')).toHaveLength(1) @@ -262,9 +440,9 @@ describe('WorkspaceBrowser', () => { expect(screen.getAllByText('新会话')).toHaveLength(1) // Search excludes blank rows entirely — neither the canonical stored // title nor the localized display label participates in matching. - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } }) expect(screen.queryByText('新会话')).toBeNull() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } }) expect(screen.queryByText('新会话')).toBeNull() }) @@ -279,7 +457,8 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) expect(screen.getByText('Needle row')).toBeTruthy() @@ -302,6 +481,27 @@ describe('WorkspaceBrowser', () => { } }) + it('collapses an empty search on outside click but keeps a non-empty query expanded', () => { + mount() + const search = screen.getByRole('button', { name: '搜索会话' }) + fireEvent.click(search) + expect(search.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + fireEvent.change(input, { target: { value: 'kept' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('true') + expect(input.value).toBe('kept') + }) + it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { vi.useFakeTimers() try { @@ -320,7 +520,7 @@ describe('WorkspaceBrowser', () => { open, searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'waterfall token' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() expect(screen.queryByText('Research notes')).toBeNull() @@ -345,7 +545,7 @@ describe('WorkspaceBrowser', () => { try { const searchSessions = vi.fn(async () => ({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') expect(input.maxLength).toBe(500) fireEvent.change(input, { target: { value: 'y'.repeat(501) } }) expect(input.value).toBe('y'.repeat(500)) @@ -376,7 +576,7 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), searchSessions, }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' }, }) expect(screen.getByText('Needle title')).toBeTruthy() @@ -413,7 +613,7 @@ describe('WorkspaceBrowser', () => { ])), searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal @@ -447,7 +647,7 @@ describe('WorkspaceBrowser', () => { ? first : Promise.resolve({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -470,7 +670,7 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getByText('暂无会话')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('无匹配会话')).toBeTruthy() @@ -486,12 +686,12 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('工作区')).toBeNull() - expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull() + expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). @@ -524,6 +724,84 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('alpha')).toBeTruthy() }) + it('uses the full expanded Workspace section when resolving a Workspace drop half', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index))) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', sessions.ids), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + fireEvent.click(screen.getByText('beta')) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (targetSection.parentElement?.getAttribute('role') !== 'tree') { + targetSection = targetSection.parentElement as HTMLElement + } + targetSection.getBoundingClientRect = () => ({ + top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + // y=190 is below the header row but still in the top half of the whole + // expanded section, so the target is before beta rather than after it. + fireDrag(targetSection, 'drop', 190) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + + it('draws the first Workspace insertion boundary on the scroll container', () => { + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + ])), + }) + const source = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement + let firstSection = screen.getByText('alpha').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (firstSection.parentElement?.getAttribute('role') !== 'tree') { + firstSection = firstSection.parentElement as HTMLElement + } + firstSection.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(firstSection, 'dragOver', 105) + expect(firstSection.parentElement?.className).toContain('listTopDropActive') + const marker = firstSection.parentElement?.previousElementSibling + expect(marker?.className).toContain('listTopDropIndicator') + }) + + it('accepts a document-level drop and commits the last Workspace marker on drag end', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (target.parentElement?.getAttribute('role') !== 'tree') { + target = target.parentElement as HTMLElement + } + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'dragOver', 105) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(source) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) @@ -538,7 +816,7 @@ describe('WorkspaceBrowser', () => { three.getBoundingClientRect = () => ({ top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) // Drop on the top half of "three": insert one before three. fireDrag(three, 'dragOver', 205) @@ -559,6 +837,55 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledTimes(1) }) + it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('未分组')) + + const dragAfter = (sourceTitle: string, targetTitle: string): void => { + const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement + const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement + target.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'drop', 180) + } + + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + dragAfter('two', 'one') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three']) + }) + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + b.view.unmount() + const restored = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('still sends the reorder when the dragged row left the group mid-drag', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 2), summary('two', 1)]) @@ -569,7 +896,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement - fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) // The host dropped "one" from the workspace account while the drag is in // flight: the source index is gone but the drop still resolves its anchor. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) @@ -594,7 +921,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragEnd(one) // The drag ended: rows no longer accept drops. @@ -608,6 +935,28 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) }) + it('accepts a document-level drop and commits the last Session marker on drag end', () => { + const insertSessionBefore = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 180) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(one) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + it('logs and keeps the order when the reorder call rejects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -623,7 +972,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireDrag(two, 'drop', 180) await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) @@ -778,7 +1127,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement expect(row.hasAttribute('draggable')).toBe(false) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/workspace-picker.spec.tsx rename to packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 16bab468df..0f64920755 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web-react", "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/web-react/tests/bind.spec.tsx b/packages/client/web-react/tests/bind.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/bind.spec.tsx rename to packages/client/web-react/tests/bind.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots-real-core.spec.tsx rename to packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots.spec.tsx rename to packages/client/web-react/tests/scoped-slots.client.spec.tsx diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/session-provider.spec.tsx rename to packages/client/web-react/tests/session-provider.client.spec.tsx diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/stale-authorization.spec.tsx rename to packages/client/web-react/tests/stale-authorization.client.spec.tsx diff --git a/packages/client/web-react/tests/use-invoke.spec.tsx b/packages/client/web-react/tests/use-invoke.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-invoke.spec.tsx rename to packages/client/web-react/tests/use-invoke.client.spec.tsx diff --git a/packages/client/web-react/tests/use-projection.spec.tsx b/packages/client/web-react/tests/use-projection.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-projection.spec.tsx rename to packages/client/web-react/tests/use-projection.client.spec.tsx diff --git a/packages/client/web/package.json b/packages/client/web/package.json index d23c343b3c..d448b71d0d 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/web/tests/app-root.spec.tsx b/packages/client/web/tests/app-root.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-root.spec.tsx rename to packages/client/web/tests/app-root.client.spec.tsx diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-shell.spec.tsx rename to packages/client/web/tests/app-shell.client.spec.tsx diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app.spec.tsx rename to packages/client/web/tests/app.client.spec.tsx diff --git a/packages/client/web/tests/base-styles.spec.ts b/packages/client/web/tests/base-styles.client.spec.ts similarity index 100% rename from packages/client/web/tests/base-styles.spec.ts rename to packages/client/web/tests/base-styles.client.spec.ts diff --git a/packages/client/web/tests/document-title.spec.tsx b/packages/client/web/tests/document-title.client.spec.tsx similarity index 100% rename from packages/client/web/tests/document-title.spec.tsx rename to packages/client/web/tests/document-title.client.spec.tsx diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 2614be990a..30648ea699 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 8d4e77136b..c3ede79ac5 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 83df4eae98..716d033855 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 75e0dcd959..17ac1fb46b 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compact-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 26f216a4dd..09c96af730 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compact-tool-result-prune", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 6f484bf8f6..0876a1d596 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compact", "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index c9dcb6af03..2d8b0289f8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferences)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index c1c222bd6f..f267433217 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 6a4d7b81d8..f12773ffab 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 9181b2aded..3016fdd849 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-context", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 6e3f6c0413..89eaf0e617 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 8bfbe38cfe..c268434dc4 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 972c321138..f4ea11aeff 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -13,6 +13,8 @@ import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, T import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() @@ -687,3 +689,74 @@ describe('tool-call scheduler: failure quiescence', () => { }) }) }) + +describe('code-mode native-tool denial through the agent loop', () => { + /** A minimal in-process code runtime for test purposes — never actually runs. */ + class FakeCodeRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'fake' as const + async run(_request: CodeRunRequest): Promise { + return { logs: [] } + } + } + + async function codeModeHarness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape + await ctx.plugin(FakeCodeRuntime as any) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx + } + + it('denies a model-direct native-tool call under code mode: tool body never runs and session records UNKNOWN_TOOL', async () => { + let toolInvoked = false + const tool = defineContentToolFixture({ + name: 'write', + description: 'Write a file.', + parameters: { + file_path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + async execute(_args, _exec) { + toolInvoked = true + return [{ type: 'text', text: 'written' }] + }, + }) + + // Scripted model emits a native tool call under code mode — the wire + // never advertised it, but a non-compliant provider may still emit one. + const adapter = new MockAdapter([ + [ + ...multiCall([{ id: 'call-1', name: 'write', args: { file_path: '/tmp/test', content: 'hello' } }]), + ...textResponse('ok'), + ], + ]) + + const ctx = await codeModeHarness(adapter) + ctx.tools.register(tool) + + const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'write a file' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + + // The tool body must NOT have executed — the collapse denied the call + // at createExecution, before the body could start. + expect(toolInvoked).toBe(false) + + // The session must record a tool/result with UNKNOWN_TOOL error so the + // transcript faithfully captures that the call was denied. + const sessionEvents = events(agent) + const toolResult = sessionEvents.find(e => e.type === 'tool/result') + expect(toolResult).toBeDefined() + expect(toolResult!.data.error).toMatchObject({ + name: 'ToolNotFoundError', + code: 'UNKNOWN_TOOL', + }) + }) +}) diff --git a/packages/core/agent-tool-mode/README.i18n.yaml b/packages/core/agent-tool-mode/README.i18n.yaml index 0799e0e547..b5cc383841 100644 --- a/packages/core/agent-tool-mode/README.i18n.yaml +++ b/packages/core/agent-tool-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-tool-mode/README.md -README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2 -README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce +README.md: f5f2df21285411dbb3b8c5cac18cc3ab0dc8a22b +README.zh.md: 84c2ff009540c2637e01875b1171c39afa6ae2f3 diff --git a/packages/core/agent-tool-mode/README.md b/packages/core/agent-tool-mode/README.md index 0ef7f32c08..f5f2df2128 100644 --- a/packages/core/agent-tool-mode/README.md +++ b/packages/core/agent-tool-mode/README.md @@ -20,7 +20,7 @@ One agent declares one presentation. A second declaration in the same compositio ## Model Experience -Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section and the rule that only `run_code` may be called directly, `native` presents every tool schema. The selection also decides what may EXECUTE: under `code` the registry resolves a model-direct call naming any other tool to `UNKNOWN_TOOL`, so this row is what keeps the announced surface and the callable surface the same for every agent it covers ([executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)). #### KV Cache effect diff --git a/packages/core/agent-tool-mode/README.zh.md b/packages/core/agent-tool-mode/README.zh.md index 974fc4ed57..84c2ff0095 100644 --- a/packages/core/agent-tool-mode/README.zh.md +++ b/packages/core/agent-tool-mode/README.zh.md @@ -20,7 +20,7 @@ preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs( ## Model Experience -Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. +间接生效,取决于它在 `dsh-tools` 中选择的投影:`code` 呈现 `run_code`、一份生成的 SDK 段,以及「只有 `run_code` 可被直接调用」这条规则,`native` 呈现每个工具的 schema。该选择同时决定了**什么可以执行**:在 `code` 下,注册表会把模型直呼其他任何工具名解析为 `UNKNOWN_TOOL`,因此这一行正是让「通告面」与「可调用面」对每个被它覆盖的 agent 保持一致的东西([执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md))。 #### KV Cache effect diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index e0d35abf0f..3f392f2024 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-mode", "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 6c85552224..b739469d16 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index d0a67e8841..a8f386e480 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 3528334b3c..02df5d6db3 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 1f411b554f..769f9df9cf 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index b121662390..5841f76969 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7 -README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165 +README.md: 44eb25b79436a75f08406102fc1e3734e59b1001 +README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 75d18712a0..44eb25b794 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -13,13 +13,13 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport, the generated `tools:sdk` section, and the `tools:code-only` rule stating that only `run_code` may be called directly — which the executor then enforces, resolving a model-direct call naming any other tool to `UNKNOWN_TOOL` before policy runs; `both` contributes both forms and states no such rule, because its native calls do execute. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. @@ -115,7 +115,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. + +Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. @@ -146,7 +148,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. Under `code` the prompt also carries the `tools:code-only` rule, ordered ahead of the per-tool guidance band so the model reads which tools it may call before it reads what each one is for; `both` renders it empty. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8d4ae42596..35142d8186 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -13,13 +13,13 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输、生成的 `tools:sdk` 段,以及声明「只有 `run_code` 可被直接调用」的 `tools:code-only` 规则——执行器随后强制该规则,模型直呼其他任何工具名都会在策略运行之前解析为 `UNKNOWN_TOOL`;`both` 同时贡献两种形式,且不声明该规则,因为它的原生调用确实会执行。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 -- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 @@ -115,7 +115,9 @@ ctx.tools.register(defineTool({ ### Code Mode -在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 +在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。 + +在 `code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token,不受此限制,因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 @@ -146,7 +148,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。在 `code` 下,提示词还会带上 `tools:code-only` 规则,其顺序排在逐工具指导段之前,让模型先读到「可以调用哪些工具」再读「每个工具做什么」;`both` 下它渲染为空。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 6634098f99..c3ef8f8ea7 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 01259b8de2..b6be912c17 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -43,6 +43,20 @@ import { renderToolsSdkPy } from './py-types.ts' * with its zh pair, plus this package's own README pair and the * {@link Config.mode} JSDoc. */ +/** + * Prompt order of the `code` collapse statement: after the persona and before + * the 100-199 per-tool guidance band, so the model reads which tools it may + * call before it reads what each one is for. + */ +const COLLAPSE_SECTION_ORDER = 99 + +/** + * The model-facing statement of the `code` collapse. Names the consequence + * (the call fails) and the route (inside the program), because a rule the + * model can only discover by being denied is one it corrects too late. + */ +const CODE_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` + const SDK_RENDERERS: Record string> = { typescript: renderToolsSdk, python: renderToolsSdkPy, @@ -313,6 +327,10 @@ export interface ToolExecutionInput { * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -474,8 +492,19 @@ export interface ToolFailure { * distinguish it from a tool body's own error. */ export class ToolNotFoundError extends HarnessError { - constructor(toolName: string) { - super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL') + /** + * @param toolName - the name the caller asked for. + * @param reachableFrom - how the model reaches this tool instead, when the + * name IS visible and only the presentation denies calling it directly. + * Omitted for a name that is registered nowhere. + */ + constructor(toolName: string, reachableFrom?: string) { + super( + reachableFrom === undefined + ? `unknown tool "${toolName}"` + : `unknown tool "${toolName}": ${reachableFrom}`, + 'UNKNOWN_TOOL', + ) this.name = 'ToolNotFoundError' } } @@ -624,16 +653,14 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation for agents that declare none of their own. `native` - * (default) sends every visible schema; `code` sends only `run_code` plus a - * generated SDK prompt; `both` sends both forms. Code modes require a - * `ctx.codeRuntime` whose `language` has a registered SDK renderer - * (TypeScript or Python) and fail prompt assembly when it is absent or has - * no renderer. Under `code`, native names in `toolOrder` are invalid. - * - * One agent overrides this for itself with {@link ToolRegistry.presentAs}, - * which is how an agent preset composes a Code Mode agent beside native - * ones in the same process. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt and collapses the + * executor to the same surface (a model-direct call may only name + * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` + * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * has a registered SDK renderer (TypeScript or Python) and fail prompt + * assembly when it is absent or has no renderer. Under `code`, native names + * in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** @@ -647,14 +674,13 @@ export interface Config { } /** - * Per-scope filter over the tools a scope INHERITS — the global layer and - * every ancestor layer on its chain. Restrictions intersect, and do not affect - * the scope's own registrations or the reserved Code Mode transport. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ export interface ToolRestriction { - /** Inherited tool names that stay visible; every other inherited one is removed. */ + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] - /** Inherited tool names removed from visibility. */ + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } @@ -670,7 +696,7 @@ interface ToolView { readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet - /** Current inherited names a scoped restriction may name; its own are exempt. */ + /** Current global names that a scoped restriction may name. */ readonly restrictableNames: ReadonlySet } @@ -708,7 +734,7 @@ class ToolLayer implements ScopeLayer { && this.mode === undefined } - /** Whether every compiled restriction in this layer admits an inherited tool name. */ + /** Whether every compiled restriction in this layer admits a global tool name. */ admits(name: string): boolean { for (const filter of this.restrictions.values()) { if ((filter.allow !== undefined && !filter.allow.has(name)) @@ -805,10 +831,37 @@ export class ToolRegistry extends Service { this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.defaultMode !== 'native') { + ctx.systemPrompt.section(this.collapseSection()) ctx.systemPrompt.section(this.sdkSection()) } } + /** + * The prompt statement of the `code` executor collapse, registered wherever + * {@link sdkSection} is and rendering empty outside an effective `code`. + * + * Every tool contributes its own guidance section naming its tool, none of + * them qualify how that tool is reached, and they all render before the SDK + * (orders 100-199 against {@link SDK_SECTION_ORDER}). Without this the model + * reads a catalog of tools it is told to use and no statement that only + * `run_code` may be called, so it emits a native call, receives + * `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes the + * deployment is inconsistent. {@link COLLAPSE_SECTION_ORDER} places the rule + * before that guidance rather than after it. + * + * `both` renders empty: native calls do execute there, so the rule is false. + * @returns the section registration. + */ + private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + return { + name: 'tools:code-only', + order: COLLAPSE_SECTION_ORDER, + // The SAME predicate the executor denies by, so the prompt cannot state + // a rule the registry does not enforce (see `collapses`). + text: context => this.modeFor(context.scope) === 'code' ? CODE_ONLY_INSTRUCTION : '', + } + } + /** * The generated-SDK prompt section, registered globally by a code-mode * deployment and per scope by {@link presentAs}. @@ -907,11 +960,14 @@ export class ToolRegistry extends Service { }, { label: 'tools.presentAs()' }, ) - // The SDK section is per scope for the same reason the mode is. Under a - // deployment that already defaults to a code mode this shadows the - // global registration with an identical body, which costs nothing and - // keeps one rule instead of a case analysis. - if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) + // The SDK and collapse sections are per scope for the same reason the + // mode is. Under a deployment that already defaults to a code mode this + // shadows the global registration with an identical body, which costs + // nothing and keeps one rule instead of a case analysis. + if (mode !== 'native') { + yield ctx.systemPrompt.section(this.collapseSection()) + yield ctx.systemPrompt.section(this.sdkSection()) + } }.bind(this), 'tools.presentAs()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity return dispose @@ -1032,7 +1088,7 @@ export class ToolRegistry extends Service { const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { - throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`) + throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) } return this.layers.effect( this.ctx, @@ -1149,6 +1205,26 @@ export class ToolRegistry extends Service { return this.view(scope).visible.get(name) } + /** + * Resolve the definition that MAY EXECUTE for a call, applying the mode + * collapse at the operation boundary that owns it. The registry view + * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `code` + * may only name the reserved `run_code` transport, while a nested + * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool + * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL` + * through the executor, matching an absent definition. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. + * @returns the definition that may run, or undefined when the call must be rejected. + */ + private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { + const tool = this.get(name, scope) + if (tool === undefined) return undefined + if (this.collapses(name, scope, nested)) return undefined + return tool + } + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -1198,7 +1274,7 @@ export class ToolRegistry extends Service { * @returns the fail-closed scheduling mode. */ executionMode(exec: ToolExecutionInput): ToolExecutionMode { - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } try { const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) @@ -1229,6 +1305,26 @@ export class ToolRegistry extends Service { } } + /** + * Whether the `code` mode collapse denies a model-direct call: only the + * reserved `run_code` transport may be named. Nested sub-dispatches (a + * `parent` token set) bypass the collapse. One home for the + * security-relevant predicate, shared by {@link resolveExecution} and + * {@link createExecution} so the two can never drift apart. + * + * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `code` + * by an agent preset under a native deployment is the composition + * `dsh-agent-tool-mode` exists for, and reading the deployment default would + * leave exactly that agent uncollapsed — announcing one surface while + * executing another, which is the bypass this collapse closes. + * @param name - the tool name as registered. + * @param scope - the viewing scope whose effective presentation mode applies. + * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. + */ + private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean { + return !nested && this.modeFor(scope) === 'code' && name !== RUN_CODE_NAME + } + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1274,8 +1370,15 @@ export class ToolRegistry extends Service { const agent = exec.agent const parent = exec.parent const signal = exec.signal - const definition = this.get(name, agent) - const finalizeContent = definition?.finalizeContent?.bind(definition) + // Distinguish a mode-collapsed call (visible in the scope, denied only by + // the `code` collapse) from a genuinely unknown tool. A collapsed call is + // deterministically denied, so it terminates BEFORE the extensible policy + // pipeline: pre-execute listeners, approval `ask`, and guards must never + // observe — or worse, approve — a call that can only fail. An unknown tool + // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy + // listeners still see every name that reaches the registry. + const visible = this.get(name, agent) + const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined) const concludingExecutions = this.concludingExecutions const base = { token, @@ -1292,6 +1395,19 @@ export class ToolRegistry extends Service { concludingExecutions.add(this as unknown as ToolExecution) }, } + // Capture the finalizer BEFORE argument materialization: the + // `finalizeContent` contract snapshots the callback when the call starts, + // and an arguments getter can replace or clear the registered callback + // during `snapshotJsonValue`. The collapse only decides whether the + // CAPTURED callback is retained: the pre-dispatch abort path keeps it + // (the cancellation contract routes aborted results through it — a getter + // that aborts mid-materialization before an invalid-args failure lands in + // the same retained path), while the `UNKNOWN_TOOL` denial and the + // invalid-args failure of a NON-ABORTED collapsed call drop it (the call + // could never execute). + const capturedFinalizer = visible?.finalizeContent?.bind(visible) + const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => + collapsed && !signal.aborted ? undefined : capturedFinalizer try { const detached = snapshotJsonValue(exec.arguments) if (detached === undefined) { @@ -1299,15 +1415,37 @@ export class ToolRegistry extends Service { } const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } this.deferredContexts.set(execution, deferredContexts) - this.contentFinalizers.set(execution, finalizeContent) + this.contentFinalizers.set(execution, finalizerFor()) this.cancellationStates.set(execution, { callerSignal: signal, bodyInvoked: false, }) + if (collapsed) { + // The collapse denies the call before the policy pipeline, but a + // pre-dispatch abort still keeps the established cancellation + // contract: `prepare`'s caller-cancellation check is skipped for + // final-results, so honor the abort here instead of surfacing + // `UNKNOWN_TOOL` on an already-cancelled call. + if (signal.aborted) { + return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } + } + // The name IS visible here, so the denial carries the route the model + // must take instead. Without it the model reads a bare `unknown tool` + // for a tool the prompt just declared and concludes the deployment is + // broken rather than correcting itself. + return { + kind: 'final-result', + exec: execution, + result: toolErrorResult(new ToolNotFoundError( + name, + `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`, + )), + } + } return { kind: 'ready', exec: execution } } catch (error: unknown) { const execution: MutableToolRunContext = { ...base, arguments: undefined } - this.contentFinalizers.set(execution, finalizeContent) + this.contentFinalizers.set(execution, finalizerFor()) return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } } } @@ -1405,7 +1543,7 @@ export class ToolRegistry extends Service { } exec.signal = signal try { - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (!tool) throw new ToolNotFoundError(exec.name) state.bodyInvoked = true const returned = await tool.execute(exec.arguments, exec) @@ -1627,7 +1765,7 @@ export class ToolRegistry extends Service { if (result.isError) { throw new TypeError('tools/post-execute cannot replace the value of a failed result') } - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (tool === undefined) throw new ToolNotFoundError(exec.name) const replaced = this.createSuccessResult(exec, tool, decision.value) return this.markCanonical(exec, { @@ -1696,7 +1834,7 @@ export class ToolRegistry extends Service { ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, }) } - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (tool === undefined) throw new ToolNotFoundError(exec.name) const normalized = this.createSuccessResult(exec, tool, result.value) return this.markCanonical(exec, { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 596f3f630a..4379f7e0b2 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -135,6 +135,32 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).not.toContain('run_code:') }) + it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + // Stand in for a real tool's guidance section, which sits in the 100-199 + // band and names its tool without saying how it is reached. + ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' }) + + const assembly = await systemPrompt.assemble() + const names = assembly.sections.map(section => section.name) + const rule = assembly.sections.find(section => section.name === 'tools:code-only') + expect(rule?.text).toContain(`\`${RUN_CODE_NAME}\` is the only tool you can call directly`) + // The rule is worthless after the guidance it qualifies. + expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tool:echo')) + expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tools:sdk')) + }) + + it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'both' }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + // Registered (the deployment is non-native) but empty, so the renderer + // drops it: `both` executes the native call the rule would forbid. + expect(assembly.sections.find(section => section.name === 'tools:code-only')?.text).toBe('') + expect(assembly.tools.map(tool => tool.name)).toContain('echo') + }) + it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => { const { ctx, systemPrompt } = await setup({ mode: 'code' }) let output: JsonSchemaNode = { type: 'string' } @@ -1561,6 +1587,43 @@ describe('the run_code dispatch bridge', () => { const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) + it('denies a model-direct native-tool call under code mode as UNKNOWN_TOOL', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx, { mode: 'code' }) + registerEcho(ctx, 'write') + const result = await registry.execute({ + signal: testToolSignal, + callId: CallId('call-1'), + name: 'write', + arguments: { text: 'hello' }, + }) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + // The name IS declared to this model, so a bare `unknown tool` reads as a + // broken deployment. The denial carries the route instead. + expect(result.error?.message).toBe( + `unknown tool "write": only \`${RUN_CODE_NAME}\` is callable directly — call \`write\` from inside a \`${RUN_CODE_NAME}\` program instead`, + ) + }) + + it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx, { mode: 'code' }) + registerEcho(ctx, 'write') + const aborted = new AbortController() + aborted.abort() + const result = await registry.execute({ + signal: aborted.signal, + callId: CallId('call-1'), + name: 'write', + arguments: { text: 'hello' }, + }) + expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + }) + }) /** @@ -1572,7 +1635,7 @@ describe('the run_code dispatch bridge', () => { describe('per-agent presentation', () => { it('gives one agent Code Mode while the deployment stays native', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) scope.ctx.tools.presentAs('code') @@ -1581,6 +1644,17 @@ describe('per-agent presentation', () => { expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) expect(coded.sections.find(section => section.name === 'tools:sdk')?.text) .toContain('echo') + // Announced surface and callable surface must agree for THIS agent, whose + // mode is its own rather than the deployment's. + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('coded-direct'), + name: 'echo', + arguments: { value: 'coded' }, + agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // The deployment default is untouched: an agent that declared nothing — // and the global view behind it — still sees the native catalog. const native = await systemPrompt.assemble() @@ -1591,7 +1665,7 @@ describe('per-agent presentation', () => { it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') @@ -1603,10 +1677,40 @@ describe('per-agent presentation', () => { expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() const coded = await systemPrompt.assemble({ scope: joined.agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // Through the EXECUTOR, not just the wire: the deployment default is + // `native` here, so a collapse predicate reading it instead of this + // scope's effective mode would announce [run_code] and still execute the + // native call — the bypass, reopened for exactly the preset composition + // `dsh-agent-tool-mode` produces. + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('preset-coded-schedule'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + })).toEqual({ kind: 'exclusive' }) + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('preset-coded-direct'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // A sibling that never parented stays native, as does the global view. expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() const native = await systemPrompt.assemble({ scope: loner.agent }) expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + const allowed = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('native-sibling-direct'), + name: 'echo', + arguments: { value: 'loner' }, + agent: loner.agent, + }) + expect(allowed).toMatchObject({ isError: false, value: 'echo:loner' }) + expect(calls).toEqual([{ value: 'loner' }]) }) it('keeps run_code out of a native agent\'s dispatch table', async () => { diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6f03e0aef4..cc868f8f85 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -190,14 +190,14 @@ describe('restrict()', () => { expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) // A scope's own registration is exempt from its own filter, so naming it // is a caller error rather than a silent no-op. - expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/) - expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s) - expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/) + expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall".*known global tools: real/s) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) - .toThrow(/Restrictable tools: \(none\)/) + .toThrow(/known global tools: \(none\)/) }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 217875898d..b0b20ff8c1 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -907,6 +907,25 @@ describe('ToolRegistry', () => { }) }) + it('keeps the normalized content when the final content transform returns undefined', async () => { + const ctx = await setup() + let finalized = 0 + ctx.tools.register({ + ...echoTool, + name: 'identity-finalizer', + finalizeContent() { + finalized += 1 + return undefined + }, + }) + + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) + + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: '' }]) + expect(finalized).toBe(1) + }) + it('a block decision can ALSO attach additionalContexts', async () => { const ctx = await setup() ctx.tools.register(echoTool) diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 977c51013c..6f3276a81b 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 8fbcf111bf..0909354c56 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index dafbb57b00..ebe99f6cf8 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 1f6e085e5f..3b87b3ca46 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index dfbdf73da5..ece30dfd8c 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 1d35f2b23f..c63f5808ff 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-demo", "description": "ACP automation server app: agent spine + JSONL persistence + ACP transport, with a JSON-RPC stdio bin", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 2092965d75..661136d17f 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 2524d75378..1224b63c7a 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jsonrpc-demo", "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index f45d504814..0a805e1537 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index fe0caa18fb..acc7fc8fae 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -45,9 +45,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 77ed82d979..437e9ff7ec 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 4f731e6559..efa65e6813 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index cc6daed922..4d0969121f 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index e9779e9d96..5af7eb2600 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index ca56b0198a..9df7901979 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 65db543d26..ed4524c227 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 5b20d4afc4..c3acc317ac 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index b87f385599..edb52363c0 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index fa42ef63d3..37c42bf3c4 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-session", "description": "Race-fenced same-session goal-round driver", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index a2f90fced3..e5e57b6b07 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -49,9 +49,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index e83648ab97..e875dd8a4e 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 27e7cc916e..f59ab2be2e 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-guard", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 4800c0f472..608f836a98 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index ad92d9dd78..a1f8519e71 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 9ed1ed7ceb..e5b218f4c2 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index a2bd73fba1..9471713885 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1345dbcbcd..a0e3b413c1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 541ebdb7a6802286b9b698575136534486387a8c -README.zh.md: 595ef03c24873272fa78ad67289b264458083614 +README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5 +README.zh.md: e1128323c45c8388562582de25cf8c68d936fac0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 541ebdb7a6..5915d20b17 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history. Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 595ef03c24..e1128323c4 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 1145ee492b..ca23793fe7 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-apiproxy", "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8bca4156a3..c9567256db 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { @@ -67,7 +67,7 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' -// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. +// Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the @@ -2758,6 +2758,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { deleted: true as const }) }, + async insertBefore(request) { + const { workspaceId, beforeWorkspaceId } = request.payload + try { + const workspaceIds = await ctx.workspace.insertBefore( + brandWorkspaceId(workspaceId), + beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), + ) + return ok(request, { workspaceIds: [...workspaceIds] }) + } catch (error: unknown) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + return workspaceNotFound(request, error.workspaceId) + } + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -2889,49 +2903,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - commands: { - // Both methods address one session's agent. agentFor resumes on miss - // and fences every subagent-owned identity with `agent-busy`; the - // api/commands.ts module contract owns that fence's wording, so this - // comment only notes the routing shape: clients send a sessionId for a - // published session, and resume restores an existing entity. - async list(request) { - // Missing service = the deployment omitted dsh-commands from its - // composition, not an empty catalog: fail loud instead of serving []. - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const found = await agentFor(request.payload.sessionId) - if ('error' in found) return err(request, found.error) - return ok(request, { commands: commands.list(found.agent) }) - }, - - async execute(request, signal) { - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const { sessionId, line } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - try { - // Pure admission: the executor's durable command/run + command/done - // pair (broadcast on the mux stream) carries the outcome; the - // response reports whether the line resolved to a handler, plus the - // minted pairing id so the issuing client can correlate its request - // with the flow node the lifecycle events produce. - const execution = await commands.execute(found.agent, line, signal) - return ok(request, execution === undefined - ? { matched: false } - : { matched: true, commandId: execution.commandId }) - } catch (error: unknown) { - if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} }) - return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} }) - } - }, - }, - goals: { // Mutations only — the read side is the 'goal' session projection. // Every verb resolves the session's agent (agentFor: implicit cold @@ -3455,9 +3426,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host(_request, signal) { const queue = new FrameQueue>() + const committedWorkspaces = ctx.workspace.list() const committedWorkspaceIds = new Set( - ctx.workspace.list().map(workspace => String(workspace.id)), + committedWorkspaces.map(workspace => String(workspace.id)), ) + let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id) // Frame-dedup baseline, same posture as committedWorkspaceIds: the // stream opens against the current set; workspace.list re-baselines // reconnecting clients, so only later changes need frames. @@ -3488,6 +3461,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (change.table === '') { if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) + const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length + && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) + && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue const workspace = ctx.workspace.get(workspaceId) @@ -3497,6 +3473,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + committedWorkspaceOrder = [...state.workspaceIds] + if (orderChanged) { + queue.push(frame({ + type: 'host/workspace-order-changed', + workspaceIds: [...state.workspaceIds], + })) + } if (state.archivedSessionIds.length !== archivedSessionIds.length || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { archivedSessionIds = state.archivedSessionIds diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts deleted file mode 100644 index c135c82e5a..0000000000 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * commands domain zod schemas (names derived from map keys: commandListRequestSchema / - * commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema). - */ - -import { z } from 'zod' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' -import type { CommandDescriptor } from './commands.ts' - -/** CommandDescriptor row of command.list. */ -export const commandDescriptorSchema = z.object({ - name: z.string().min(1), - description: z.string(), - input: z.object({ hint: z.string() }).optional(), -}) satisfies z.ZodType> - -/** command.list request payload. */ -export const commandListRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** command.list response value. */ -export const commandListValueSchema = z.object({ - commands: z.array(commandDescriptorSchema), -}) satisfies z.ZodType>> - -/** command.execute request payload. */ -export const commandExecuteRequestSchema = z.object({ - sessionId: sessionIdSchema, - line: z.string(), -}) satisfies z.ZodType>> - -/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ -export const commandIdSchema = z.string().min(1) as unknown as z.ZodType - -/** command.execute response value: pure admission — outcomes ride the logged - * lifecycle events; commandId (present exactly when matched) correlates with them. */ -export const commandExecuteValueSchema = z.object({ - matched: z.boolean(), - commandId: commandIdSchema.optional(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts deleted file mode 100644 index 984a184093..0000000000 --- a/packages/host/apiproxy/src/api/commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * commands domain contract: the web catalog/dispatch face of the host command - * registry (`ctx.commands`). Both methods address an ordinary session's Agent - * via `sessionId`, resuming it when cold. Session-backed subagents reject with - * `agent-busy` and retain their dedicated continuation owner. - */ - -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** - * Handler-free command view served to clients. Wire mirror of the host - * registry descriptor (which stays host-side with its cordis dependencies); - * no source field — the host descriptor has none. - */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: { readonly hint: string } -} - -/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */ -export interface CommandsApi { - /** - * Lists the addressed agent's effective command catalog (name-sorted, - * globals plus its scoped shadows). Session-backed subagents reject with - * `agent-busy`. - */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Parses and executes one slash-command line against the addressed agent - * without sending it to the model — pure admission semantics. matched=false - * when syntax or name does not resolve (the client falls back to its - * default sink). The handler's outcome does NOT ride the response: the host - * executor durably logs the lifecycle (`command/run`/`command/done`), which - * broadcasts on the mux stream and renders as a persistent flow node. - * `commandId` is present exactly when matched — the minted lifecycle - * pairing id, letting the issuing client correlate this acknowledgment - * with that flow node. The signal rides beside the request, never on the - * wire: the fetch carrier's request signal cancels the running handler. - * Session-backed subagents reject with `agent-busy` before dispatch. - */ - execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): - Promise> -} diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index c8ddf99e8d..8b88186582 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), // args stays wide, the same posture as session/projection's value: the frame // arrives from JSON.parse, so every element is already a JSON value, and the diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 901379b181..beba99b595 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -119,7 +119,8 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion; archived-sessions-changed pushes the full registry + * session-log deletion; workspace-order-changed pushes the complete durable + * registry order after a reorder; archived-sessions-changed pushes the full registry * archive set after every durable change (same full-snapshot posture as * workspace-changed — `workspace.list` re-baselines it on reconnect). */ @@ -138,6 +139,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * One allowlisted host cordis event forwarded verbatim. The allowlist is diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5f72192969..7c7b43d650 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' @@ -25,7 +24,6 @@ export interface ApiProxy { subagents: SubagentsApi host: HostApi workspace: WorkspaceApi - commands: CommandsApi skills: SkillsApi agentPresets: AgentPresetsApi events: EventsApi @@ -52,7 +50,6 @@ export type { } from './subagents.ts' export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' -export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..80dede1799 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -48,10 +47,9 @@ export interface RpcMethodMap { 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] + 'workspace.insertBefore': WorkspaceApi['insertBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] - 'command.list': CommandsApi['list'] - 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index b508e25f93..177f0ffd29 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -91,6 +91,9 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion /** ServerRequest full form (payload stays wide). */ @@ -119,7 +122,7 @@ export const serverRequestSchema = z.object({ export const clientResponseSchema = z.object({ type: z.literal('client-response'), rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown()), + result: rpcResultSchema(z.unknown().optional()), }) as unknown as z.ZodType /** Wire full-form union (discriminated by type). */ diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 5ad5a0b96b..b57305141c 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({ deleted: z.literal(true), }) satisfies z.ZodType>> +/** workspace.insertBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + beforeWorkspaceId: workspaceIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertBefore response value: the complete durable display order. */ +export const workspaceInsertBeforeValueSchema = z.object({ + workspaceIds: z.array(workspaceIdSchema), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 64feb27f80..d36d0c406e 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -73,6 +73,15 @@ export interface WorkspaceApi { delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): Promise> + /** + * Moves one Workspace within the registry display order, + * DOM-insertBefore-like. An omitted anchor appends to the end. + */ + insertBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + beforeWorkspaceId?: WorkspaceId + }>): Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3c74ad04ac..70e3ece58f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -35,11 +35,11 @@ import { workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, } from '../api/workspace.schema.ts' -import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -117,13 +117,10 @@ export interface IApiClient { create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> + insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } - commands: { - list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> - execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -198,10 +195,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), + insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } - readonly commands: IApiClient['commands'] = { - list: (payload, signal) => this.callUnary('command.list', payload, signal), - // Command handlers are user-driven operations and may legitimately exceed - // the transport health deadline. Caller/connection aborts remain. - execute: (payload, signal) => this.callUnary( - 'command.execute', payload, signal, 'caller-signal-only', - ), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..4e8800348a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -38,11 +38,11 @@ import { workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' -import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -113,10 +113,9 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, + 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, - 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, - 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ca0cf0329b..43b4c5d5b0 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly subagents: ApiProxy['subagents'] readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] - readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.subagents = api.subagents this.workspace = api.workspace this.host = api.host - this.commands = api.commands this.goals = api.goals this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts deleted file mode 100644 index 9abe116d31..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' -/** - * Command/skill RPC handlers and the two new frames over createApiProxy: - * command.list serves the addressed agent's effective catalog (missing - * registry = loud internal error), command.execute dispatches through the - * registry with the carrier signal, skill.list resolves cwd from the session - * header (never via the Agent registry), the host stream broadcasts - * commands-changed, and the mux stream carries live queued frames plus the - * open-time queue snapshot. - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import CommandService from '@deepseek-ai/dsh-commands' -import SkillService from '@deepseek-ai/dsh-skill' -import type { HostFrame } from '../src/api/index.ts' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' -import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } - -function request

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} -let nextRpc = 1 - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -/** Composition floor for the command/skill paths (no LLM, no persistence). */ -async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.skills !== false) await ctx.plugin(SkillService, {}) - if (options.commands !== false) await ctx.plugin(CommandService) - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices here — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ -function stubAgent(ctx: Context, sessionId?: SessionId): Agent { - const session = ctx.sessions.create(sessionId) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { - id: session.id, - session, - inbox, - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - return agent -} - -/** Drain `count` frames from a stream, then abort it. */ -async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: F[] = [] - for await (const frame of iterable) { - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - return frames -} - -/** Read the next payload from an open stream. */ -async function nextFrame(iterator: AsyncIterator>): Promise { - const result = await iterator.next() - if (result.done) throw new Error('stream ended') - return result.value.payload -} - -describe('command.list', () => { - it('serves the addressed agent\'s name-sorted catalog', async () => { - const ctx = await harness() - ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) }) - ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '' }, handler: () => ({ kind: 'success' }) }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.list(request({ sessionId: agent.id }))) - expect(value.commands).toEqual([ - { name: 'alpha', description: 'a', input: { hint: '' } }, - { name: 'zeta', description: 'z' }, - ]) - }) - - it('fails loud with internal when the command registry is not mounted', async () => { - const ctx = await harness({ commands: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('command registry') - }) -}) - -describe('command.execute', () => { - it('executes a known command against the addressed agent and detaches the result', async () => { - const ctx = await harness() - let received: string | undefined - ctx.commands.register({ - name: 'goal', - description: 'set goal', - handler: (invocation) => { - received = invocation.rawInput - return { kind: 'success', text: `goal:${invocation.agent.id}` } - }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal)) - expect(value).toMatchObject({ matched: true }) - expect(value.commandId).toBeTruthy() - expect(received).toBe(' ship it') - // Pure admission on the wire: the outcome rides the durably logged - // lifecycle pair instead of the response. - const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done') - expect(lifecycle).toMatchObject([ - { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } }, - { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } }, - ]) - }) - - it('returns matched:false when syntax or name does not resolve', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const signal = new AbortController().signal - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false }) - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false }) - }) - - it('maps a session miss to session-not-found and a registry gap to internal', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const missing = expectErr(await api.commands.execute( - request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate - - const bare = await harness({ commands: false }) - const bareApi = createApiProxy(bare, DEFAULTS) - expect(expectErr(await bareApi.commands.execute( - request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal') - }) - - it('reports an aborted handler as cancelled and a throwing handler as internal', async () => { - const ctx = await harness() - ctx.commands.register({ - name: 'hang', - description: 'never settles on its own', - handler: () => new Promise(() => { /* settled only by abort */ }), - }) - ctx.commands.register({ - name: 'boom', - description: 'throws', - handler: () => { throw new Error('kaboom') }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - - const controller = new AbortController() - const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal) - controller.abort() - expect(expectErr(await pending).code).toBe('cancelled') - - const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal)) - expect(thrown.code).toBe('internal') - expect(thrown.message).toContain('kaboom') - }) -}) - -describe('skill.list', () => { - it('lists skills for the session cwd taken from the header', async () => { - const ctx = await harness() - const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: (options) => { - seenCwds.push(options.cwd) - return Promise.resolve([ - { - name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'trusted-only', description: 'Trusted-only', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ]) - }, - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - // No agent is registered for this session: header resolution must not - // touch (or resume through) the Agent registry. - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, - { name: 'user-only', description: 'User-only', modelInvocable: false }, - ]) - expect(seenCwds).toEqual(['/proj']) - expect(ctx.agents.get(session.id)).toBeUndefined() - }) - - it('fails loud on an unattached session id (business error, no resume attempt)', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId }))) - expect(error.code).toBe('session-not-found') - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const error = expectErr(await api.skills.list(request({ sessionId: session.id }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) - - it('folds a provider failure into internal', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'broken', - list: () => Promise.reject(new Error('directory exploded')), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const response = await api.skills.list(request({ sessionId: session.id })) - // dsh-skill contains one provider's failure (logs and serves the rest), so - // this surfaces as an empty ok catalog rather than an error. - const value = expectOk(response) - expect(value.skills).toEqual([]) - }) -}) - -describe('forwarded commands/change frame', () => { - it('broadcasts on registry change', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const abort = new AbortController() - const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) }) - // Verbatim forwarding: the wire name is the host's own event name and - // `args` is its argument list (empty for this pure invalidation). - expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }]) - }) - - // The guard belongs to the forwarding boundary, so it is tested there rather - // than through a malformed `ctx.emit`: every currently allowlisted event has a - // statically JSON-safe payload, so no type-legal emit can reach the rejection - // branch. These cases stand in for a future allowlist entry whose payload the - // wire cannot carry — a composition mistake that must fail loud. - describe('assertJsonArgs', () => { - it('passes a JSON-safe argument list through unchanged', () => { - const args = ['llm-deepseek', 7, null, { nested: ['ok'] }] - expect(assertJsonArgs('settings/document-updated', args)).toEqual(args) - expect(assertJsonArgs('commands/change', [])).toEqual([]) - }) - - it('names the offending event and argument position when a payload is not lossless JSON', () => { - expect(() => assertJsonArgs('credentials/updated', [1n])) - .toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data') - expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}])) - .toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data') - }) - }) -}) - -/** Build one frozen inbox message. */ -function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { - return freezeMessage({ - id: MessageId(id), - role: 'user', - content: [{ type: 'text' as const, text }], - source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) }, - }) -} - -describe('session.updateQueue', () => { - it('splices a queued message and reports a lost claim race', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const present = inboxMessage('present', 'before') - agent.inbox.splice('next-turn', 0, 0, [present]) - const api = createApiProxy(ctx, DEFAULTS) - - const applied = await api.sessions.updateQueue({ - rpcId: RpcId('q-apply'), - payload: { - sessionId: agent.id, - itemId: MessageId('present'), - action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, - }, - }) - expect(expectOk(applied)).toEqual({ accepted: true }) - const missing = await api.sessions.updateQueue({ - rpcId: RpcId('q-missing'), - payload: { - sessionId: agent.id, - itemId: MessageId('claimed'), - action: { kind: 'remove' }, - }, - }) - expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) - expect(agent.inbox.nextTurn[0]).toMatchObject({ - id: 'present', - content: [{ type: 'text', text: 'edited' }], - }) - }) - - it('rejects a stale occurrence without resuming a cold agent', async () => { - const ctx = await harness() - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.updateQueue({ - rpcId: RpcId('q-cold'), - payload: { - sessionId: 'cold-session' as SessionId, - itemId: MessageId('stale-item'), - action: { kind: 'remove' }, - }, - }) - - expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' }) - expect(resume).not.toHaveBeenCalled() - }) -}) - -describe('session/queue frames', () => { - it('publishes authoritative inbox snapshots without duplicating message identity', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const queued = inboxMessage('m-1', 'queued prompt') - const edited = inboxMessage('m-1', 'edited prompt') - const steering = inboxMessage('m-2', 'steering prompt') - agent.inbox.splice('next-turn', 0, 0, [queued]) - agent.inbox.splice('next-step', 0, 0, [steering]) - - const abort = new AbortController() - const iterator = api.events.mux({ - rpcId: RpcId('t-mux-baseline'), - payload: {}, - }, abort.signal)[Symbol.asyncIterator]() - const frames = [ - await nextFrame(iterator), - await nextFrame(iterator), - ] - agent.inbox.splice('next-turn', 0, 1, [edited]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - const injected = freezeMessage({ - id: MessageId('m-3'), - role: 'user', - content: [{ type: 'text' as const, text: 'injected context' }], - source: { kind: 'plugin' as const, plugin: 'approval' }, - }) - agent.inbox.splice('next-step', 0, 0, [injected]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - abort.abort() - await iterator.return?.() - - expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([ - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: queued.id, placement: 'queued', message: queued }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: injected.id, placement: 'context', message: injected }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - ]) - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 7c3c39e9e1..4c2506c395 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -319,6 +319,50 @@ describe('workspace.create', () => { }) }) +describe('workspace.insertBefore', () => { + it('commits the complete order, streams one order frame, and maps unknown ids', async () => { + const { api, ctx, root } = await harness() + const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace + const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace + const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace + + const abort = new AbortController() + const listWorkspaces = vi.spyOn(ctx.workspace, 'list') + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + expect(listWorkspaces).toHaveBeenCalledTimes(1) + const changed = nextHostFrame(stream) + const reordered = expectOk(await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: second.workspaceId, + }))) + expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId]) + expect(await changed).toMatchObject({ + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId], + }, + }) + expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId)) + .toEqual(reordered.workspaceIds) + + const missingSource = await api.workspace.insertBefore(request({ + workspaceId: 'missing' as WorkspaceId, + })) + expect(missingSource.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } }, + }) + const missingAnchor = await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: 'missing-anchor' as WorkspaceId, + })) + expect(missingAnchor.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } }, + }) + abort.abort() + }) +}) + describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fdee096348..4130d8f210 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -21,7 +21,6 @@ function scriptedApi(overrides: { sessions?: Partial subagents?: Partial host?: Partial - commands?: Partial skills?: Partial agentPresets?: Partial events?: Partial @@ -86,14 +85,10 @@ function scriptedApi(overrides: { create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), + insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, - commands: { - list: r => ok(r, { commands: [] }), - execute: r => ok(r, { matched: false }), - ...overrides.commands, - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), @@ -224,7 +219,7 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) }) - it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and ordering through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) @@ -233,6 +228,11 @@ describe('unary round trip', () => { expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + const workspaceOrder = await c.workspace.insertBefore({ + workspaceId: 'w1' as never, + beforeWorkspaceId: 'w2' as never, + }) + expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 1888cf602a..2000f708ba 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,4 +1,3 @@ -import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' @@ -180,6 +179,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async delete(request) { return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } }, + async insertBefore(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, @@ -190,25 +192,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } }, }, - commands: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } } - }, - async execute(request, signal) { - if (request.payload.line === '/hang') { - // Cooperative hang: settles only through the carrier signal (sticky - // abort checked first — listeners never fire retroactively). - if (!signal.aborted) { - await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - } - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } } - } - if (request.payload.line.startsWith('/plan')) { - return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } } - } - return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -441,19 +424,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips command.list / command.execute / skill.list through the wire form', async () => { + it('round-trips skill.list through the wire form', async () => { const c = client() - const list = await c.commands.list({ sessionId: 's' as never }) - expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } }) - const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' }) - expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } }) - const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) - expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets command.execute finish after the 30-second default unary deadline', async () => { + it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { vi.useFakeTimers() const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { const controller = new AbortController() @@ -464,16 +441,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) try { const api = fakeApi() - api.commands.execute = async (request) => { + api.host.pickDirectory = async (request) => { await new Promise(resolve => setTimeout(resolve, 30_001)) - return { - rpcId: request.rpcId, - result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } } - const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const execution = client(api).host.pickDirectory({}) const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + result: { ok: true, value: { path: '/tmp/slow' } }, }) await Promise.all([ @@ -509,10 +483,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { })).result).toEqual({ ok: true, value: { accepted: true } }) }) - it('keeps caller and connection aborts on command.execute', async () => { + it('keeps caller and connection aborts on a deadline-exempt unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.commands.execute = async (request, signal) => { + api.host.pickDirectory = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -525,10 +499,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).commands.execute( - { sessionId: 's' as never, line: '/hang' }, - controller.signal, - ) + const execution = client(api).host.pickDirectory({}, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) @@ -537,20 +508,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into command.execute', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) - // The fake's /hang settles only when the invoke-level signal aborts: a - // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) - controller.abort() - const response = await pending - const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } - expect(parsed.rpcId).toBe('r-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into session.search', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9a8f9ecc55..b78a03de07 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -23,14 +23,11 @@ import { workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' -import { - commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, - commandListRequestSchema, commandListValueSchema, -} from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -80,6 +77,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') + // The credentials producer still emits this code, so the branch has to stay. + expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -115,9 +114,14 @@ describe('wire full-form schemas', () => { expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) - it('rejects a quadrant missing its members', () => { + it('rejects a quadrant missing its members but accepts a valueless success result', () => { expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + // A void business result carries no value field; the endpoint's own second + // parse is what requires a value for methods that return data. + expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId) + .toBe('r1') }) }) @@ -364,6 +368,13 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() @@ -385,43 +396,15 @@ describe('workspace domain schemas', () => { expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) - it('insertSessionBefore accepts an anchored and an anchorless move', () => { - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() - expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() - expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) -}) - -describe('commands domain schemas', () => { - it('validates the catalog request/value pair', () => { - expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - // The wire is session-addressed only: a sessionId-less payload fails. - expect(() => commandListRequestSchema.parse({})).toThrow() - expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([]) - const value = commandListValueSchema.parse({ commands: [ - { name: 'plan', description: 'Toggle plan mode' }, - { name: 'goal', description: 'Set the goal', input: { hint: '' } }, - ] }) - expect(value.commands[1]?.input?.hint).toBe('') - expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined() - expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow() - expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow() - }) - - it('validates the execute request/value pair with both matched branches', () => { - expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off') - // Both members are mandatory: dropping either fails the parse. - expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() - expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() - expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) - // Pure admission: matched plus the optional lifecycle pairing id - // (outcomes ride the logged lifecycle events, never this response). - expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' })) - .toEqual({ matched: true, commandId: 'cmd-1' }) - expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true }) - expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow() - expect(() => commandExecuteValueSchema.parse({})).toThrow() + it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { + expect(workspaceInsertBeforeRequestSchema.parse({ + workspaceId: 'w1', beforeWorkspaceId: 'w2', + }).beforeWorkspaceId).toBe('w2') + expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) + .toBeUndefined() + expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() + expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) + .toEqual(['w2', 'w1']) }) }) diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 4ad110d48f..0c791b79a2 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -32,21 +32,25 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 91343463fd..4f1ee77b98 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -1,12 +1,13 @@ /** * Adaptive chooser of the directory-picker seam: resolves the host's * situation once at boot (bind host, SSH launch, display session, Linux - * chooser binary) and mounts the matching dual-face backend — `-native` or - * `-browse` — as a real Loader entry in the in-memory root tree. Because the - * backend arrives as an ordinary entry, its browser half is discovered - * exactly as a config-row's would be, so the seam's one-row-swaps-both-faces - * invariant holds for the resolved choice; pinning an interaction remains - * composing that backend row directly instead of this one. + * chooser binary) and mounts the matching interaction — `native` or `browse` + * — as real Loader entries in the in-memory root tree. Each interaction is a + * pair: the Host backend serving the seam capability and the client surface + * occupying ui-workspace's directory-flow holes. Both arrive as ordinary + * entries, so the surface is discovered exactly as a config-row's would be + * and one resolved choice still swaps both faces; pinning an interaction + * remains composing that pair directly instead of this row. * @module @deepseek-ai/dsh-host-directory-picker-auto */ @@ -28,7 +29,7 @@ export const name = 'directory-picker-auto' export const inject = ['httpServer', 'loader'] /** - * Backend package per resolved kind — fixed composition vocabulary, not a + * Host backend package per resolved kind — fixed composition vocabulary, not a * tunable. Exported because the reference is a runtime string the static * config gate cannot see in a yml row: `verify-cordis-config` requires every * app composing this chooser to declare both values as dependencies. @@ -39,10 +40,23 @@ export const BACKEND_PACKAGES: Record = { } /** - * Resolve the backend from one boot-time sample and mount it as a Loader - * entry; the effect's disposer removes the entry and joins the backend - * fiber's teardown, so unloading this plugin returns only after both faces - * of the mounted backend (and their dependents) quiesced. + * Client surface package per resolved kind, mounted with its backend so one + * resolved interaction still composes both faces. Declared as dependencies by + * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the + * specifier is referenced here — the packages belong to the Client program, so + * no import of them exists on this side and knip needs them ignored for this + * workspace. + */ +export const SURFACE_PACKAGES: Record = { + native: '@deepseek-ai/dsh-client-ui-directory-picker-native', + browse: '@deepseek-ai/dsh-client-ui-directory-picker', +} + +/** + * Resolve the interaction from one boot-time sample and mount its backend and + * surface as Loader entries; the effect's disposer removes both entries and + * joins their fibers' teardown, so unloading this plugin returns only after + * both faces of the mounted interaction (and their dependents) quiesced. * @param ctx - cordis context carrying the injected `httpServer` and `loader`. */ export async function apply(ctx: Context): Promise { @@ -54,16 +68,31 @@ export async function apply(ctx: Context): Promise { }) await ctx.effect(async () => { // Root-tree create: the Loader root is in-memory (write() is a no-op), so - // the mounted row can never be persisted back into a config file. - const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] }) - return async () => { - // Tree teardown (group.stop) can have removed the entry already; - // nothing is left to unmount or await then. - const entry = ctx.loader.store[id] - if (entry === undefined) return - // remove() disposes the entry transactionally, so the chooser's unload - // signals completion only after the backend quiesced. - await ctx.loader.remove(id) + // the mounted rows can never be persisted back into a config file. The + // backend lands first: the surface's browser half drives the capability + // the backend registers. + const ids: string[] = [] + const unmount = async () => { + for (const id of [...ids].reverse()) { + // Tree teardown (group.stop) can have removed the entry already; + // nothing is left to unmount or await then. + if (ctx.loader.store[id] === undefined) continue + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after that face quiesced. + await ctx.loader.remove(id) + } } - }, 'directory-picker-auto: backend entry') + try { + for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { + ids.push(await ctx.loader.create({ name })) + } + } catch (cause) { + // Setup owns the entries it created until it returns the disposer: leaving + // the backend mounted would make a retry collide with its own + // directoryPicker registration. + await unmount() + throw cause + } + return unmount + }, 'directory-picker-auto: interaction entries') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index ed9bfd1e44..4e19936300 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -1,10 +1,10 @@ /** * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row plus the adaptive chooser, and the - * assertions observe the durable outcome — which backend entry the chooser - * mounted into the Loader store, the capability the seam then serves, and - * that disposing the chooser removes the mounted entry again (HMR safety), - * joining the backend's own teardown before the disposer settles. + * assertions observe the durable outcome — which backend and surface entries + * the chooser mounted into the Loader store, the capability the seam then + * serves, and that disposing the chooser removes both mounted entries again + * (HMR safety), joining the backend's own teardown before the disposer settles. */ import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' @@ -48,6 +48,23 @@ vi.mock('node:fs/promises', async (importOriginal) => { const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' +const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native' +const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker' + +/** + * Loader-visible stand-in for a client surface package: the surfaces belong to + * the Client program and publish browser entry points only, so a Host-face spec + * can neither name them in a static import nor resolve them from source. What + * the chooser owns is the mounting decision, which every case observes through + * the Loader store; the surface's own browser contributions belong to the + * assembled web coverage. + * + * @param name Surface package specifier the chooser mounts. + * @returns A function-plugin module the Loader can mount under that specifier. + */ +function surfaceModule(name: string): unknown { + return { name, apply: () => undefined } +} let root: string | undefined let fakeBin: string | undefined @@ -71,7 +88,10 @@ afterEach(async () => { }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ -async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { +async function loadComposition( + bindHost: '127.0.0.1' | '0.0.0.0', + options: { failSurface?: boolean } = {}, +): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -92,10 +112,15 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx [AUTO, DirectoryPickerAuto], [NATIVE, NativeDirectoryPicker], [BROWSE, BrowseDirectoryPicker], + [NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)], + [BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)], ]) context.loader.internal = { version: 'v2', async import(specifier: string) { + if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) { + throw new Error(`surface import failed: ${specifier}`) + } if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) return modules.get(specifier) }, @@ -142,7 +167,9 @@ describe('real Loader composition', () => { .map(entry => entry.options.name) expect(unloaded).toEqual([]) expect(entryNames(ctx)).toContain(NATIVE) + expect(entryNames(ctx)).toContain(NATIVE_SURFACE) expect(entryNames(ctx)).not.toContain(BROWSE) + expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('native') // The mounted row lives in the Loader's in-memory root tree only — the @@ -155,6 +182,7 @@ describe('real Loader composition', () => { const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await autoEntry.fiber!.dispose() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() // Self-disposing an include-tree entry persists `disabled: true` (loader // behavior, not the chooser's); await that debounced write so it cannot @@ -170,7 +198,9 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('127.0.0.1') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('browse') }) @@ -180,7 +210,20 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('0.0.0.0') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) + }) + + it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => { + stubAttendedHost() + await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/) + + // Setup owns both entries until it returns its disposer, so a failed surface + // must take the mounted backend with it: otherwise a retry collides with the + // directoryPicker registration this backend already made. + expect(entryNames(context!)).not.toContain(NATIVE) + expect(context!.get('directoryPicker')).toBeUndefined() }) it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => { @@ -193,6 +236,7 @@ describe('real Loader composition', () => { renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') expect(renameControl.injectedFailures).toBe(1) diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 3b42032e16..dbad0318a6 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -22,55 +22,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" - } + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json index 00dcdf8fde..b6a0f96d7e 100644 --- a/packages/host/directory-picker-browse/tsconfig.json +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -16,21 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/ui-primitives" - }, - { - "path": "../../client/locale" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts index 4b2be38c3d..388cecbcb5 100644 --- a/packages/host/directory-picker-browse/tsdown.config.ts +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -1,3 +1,15 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) +/** Node-only backend: listing and creation primitives over the host filesystem. */ +export default defineConfig([ + { + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 01817a238e..fbc7ae16d5 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -22,10 +22,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./worker": { "types": "./lib/types/win32-dialog-worker.d.ts", "default": "./lib/worker.cjs" @@ -37,7 +33,6 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -47,30 +42,12 @@ "koffi": "^3.1.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", "tsx": "^4.19.2" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace" - ], - "platform": "web" - } } } diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 395595e836..6962312bd1 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -19,15 +19,6 @@ }, { "path": "../../util/native-command" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 6d02727f4e..13a7f74070 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,23 +1,31 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -// The Win32 dialog worker builds as its own CJS entry (mirroring -// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining -// the dialog logic while koffi stays an external native require. -export default clientBundle( - '@deepseek-ai/dsh-host-directory-picker-native', - ['lib/types/index.js', 'lib/types/invariant.js'], +/** + * Node-only backend. The Win32 dialog worker builds as its own CJS entry + * (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver, + * inlining the dialog logic while koffi stays an external native require. + */ +export default defineConfig([ { - companions: [{ - // The artifact is lib/worker.cjs (the ./worker export the workspace - // constraint keys on), bundled from the descriptive source entry. - entry: { worker: 'lib/types/win32-dialog-worker.js' }, - outDir: 'lib', - format: ['cjs'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, }, -) + { + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 0540414965..8b752bab20 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 61c144abe9..0065234841 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 016ae8f253..1f3aba254e 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 1db5c770d6..59ce5dcebe 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -30,6 +30,14 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -37,7 +45,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -46,14 +58,19 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 56b5d4da14..fb281cc543 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,26 +3,27 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' import { CommandId } from './brand.ts' +import type { + CommandDescriptor, + CommandExecution, + CommandInputDescriptor, + CommandResult, +} from './types.ts' export { CommandId } from './brand.ts' -export type { CommandSource, CommandSourceMap } from './types.ts' +export type * from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Immutable metadata for a command's optional unstructured input. */ -export interface CommandInputDescriptor { - /** Placeholder shown before the user supplies free-form input. */ - readonly hint: string -} - /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -35,29 +36,6 @@ export interface CommandInvocation { readonly signal: AbortSignal } -/** Expected command outcome rendered directly by the dispatching UI. */ -export type CommandResult = - | { - readonly kind: 'success' - readonly text?: string - /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number - } - | { readonly kind: 'error'; readonly text: string } - -/** - * One settled command execution: the handler's normalized result plus the - * lifecycle pairing id minted for its `command/run`/`command/done` records, - * so a dispatching surface can correlate the RPC-level acknowledgment with - * the flow node those events produce. - */ -export interface CommandExecution { - /** Pairing id carried by this execution's lifecycle events. */ - readonly commandId: CommandId - /** The handler's normalized outcome. */ - readonly result: CommandResult -} - /** Plugin-owned command registration. */ export interface CommandDefinition { /** Lowercase command name without the leading slash. */ @@ -76,16 +54,6 @@ export interface CommandDefinition { readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } -/** Handler-free immutable command view returned to UI adapters. */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: CommandInputDescriptor -} - /** Syntactically valid slash command before registry resolution. */ export interface ParsedCommand { /** Lowercase command name without the leading slash. */ @@ -254,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult { * registered through a command-injected child of an agent context shadow * globals for that agent. */ -export class CommandService extends Service { +export class CommandService extends GatewayService { private readonly layers = new ScopedLayers( scope => new CommandLayer(scope), () => { this.notifyChange() }, @@ -288,6 +256,7 @@ export class CommandService extends Service { * @param agent - exact receiving agent and scoped-layer key. * @returns name-sorted descriptors after scoped shadowing. */ + @Remote list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] .map(command => command.descriptor) @@ -324,6 +293,7 @@ export class CommandService extends Service { * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ + @Remote async execute( agent: Agent, line: string, diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 27309355d4..32f1dbcc43 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,6 +9,45 @@ import type { CommandId } from './brand.ts' +/** Immutable metadata for a command's optional unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } + | { readonly kind: 'error'; readonly text: string } + +/** + * One settled command execution: the handler's normalized result plus the + * lifecycle pairing id minted for its `command/run`/`command/done` records, + * so a dispatching surface can correlate the Remote acknowledgment with the + * flow node those events produce. + */ +export interface CommandExecution { + /** Pairing id carried by this execution's lifecycle events. */ + readonly commandId: CommandId + /** The handler's normalized outcome. */ + readonly result: CommandResult +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} + /** * Producer record for one command invocation (the `command/run` event's * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 901c76a377..128d7d9147 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 054773f691..6d0a0773d7 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission", "description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index dc025017e0..889f07c19b 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userInteraction seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 37b29c4697..d4a9dfd8cf 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index fe90d7534c..fe65552533 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-interaction", "description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 52b50283a8..2e2338d10d 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 3d36802a3e..cbef9b89df 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index da8358c620..b00ffdb2e6 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 523ab16cb3..28f68802a5 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index ef08734fe2..f1158c0300 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index f31e48080f..33ada9d841 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-local", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 6395749c68..57e5d0f876 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index d4003a61e5..df9cb7a2ad 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 1cd533c3db..cc68494a17 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 33b536d3ee..386cd76b96 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 559144f6df..d63dd3fe2a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d -README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815 +README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967 +README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 28b9a31ed4..63bed95d19 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. -- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. +- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. - `ctx.agentPresets.copy(from, id, name?): Promise` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows. - `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. @@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed, |---|---|---| | `default` | required | Preset id mounted when a caller names none | | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | +| `includeUserRoot` | `true` | Append `/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The writable root is this package's, the shipped root is the app's + +`/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. + +The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. + +`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. + +The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. + ### The default preset is a user setting When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: @@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work +- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`. - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1d8d1481c2..505cb017a3 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -18,7 +18,8 @@ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 -- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 +- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 - `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 - `ctx.agentPresets.copy(from, id, name?): Promise` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 - `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 @@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 可写根目录属于本包,随附根目录属于 app + +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 + +根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 + +`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 + +随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 + ### 默认 preset 是一项用户设置 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: @@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work +- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 - **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 2a7dd7da63..08af1af854 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 4ab3f67e50..51e1f30c95 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * Harness-home directory holding locally authored presets. + * + * This package owns the writable root the way `dsh-skill-local` owns + * `/skills`. An app must assemble the SHIPPED root, whose path only + * the installed app can resolve; where a person's own presets go is the same + * place in every deployment that does not say otherwise, so a launcher that + * forgets to configure one still finds them. + * + * Package-internal on purpose: no consumer outside this package addresses the + * directory by name, and a test that imported it could not catch this value + * being wrong — the expected segment is spelled out where it is asserted. + */ +export const USER_PRESET_DIR = '.agent-presets' + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3da5e3b5c9..a98eb92dd4 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' -import { discoverPresets } from './discovery.ts' +import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -88,8 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeUserRoot: z.boolean().default(true), }) as z + /** + * The roots discovery and authoring actually scan: every configured root in + * order, then the harness-home user root unless `includeUserRoot` is false. + * + * Derived once, because a root set that changed between `list()` and the + * `copy()` acting on its answer would author into a directory the caller + * never saw. Appending rather than prepending keeps an earlier configured + * root winning a duplicate id, so a shipped preset still shadows a + * locally authored directory that claimed its name. + */ + private readonly resolvedRoots: readonly PresetRoot[] + /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded @@ -116,6 +130,9 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx + this.resolvedRoots = config.includeUserRoot + ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] + : [...config.roots] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -147,7 +164,7 @@ export class AgentPresets extends Service { // does that today — the Web surface mounts in `setup` and children join // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { - if (this.config.roots.length === 0) return + if (this.resolvedRoots.length === 0) return if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` @@ -180,7 +197,7 @@ export class AgentPresets extends Service { * @returns the presets, first-root-wins per id. */ async list(): Promise { - return await discoverPresets(this.config.roots) + return await discoverPresets(this.resolvedRoots) } /** @@ -320,9 +337,19 @@ export class AgentPresets extends Service { return standingMountFor(agentCtx)?.presetId } - /** Whether this deployment configures a root locally authored presets go to. */ + /** + * The roots this roster scans, which is not `config.roots`: it is every + * configured root in order, then the harness-home user root unless + * `includeUserRoot` is false. Read this — not the config field — to answer + * whether a roster is composed at all, so one derivation decides it. + */ + get roots(): readonly PresetRoot[] { + return this.resolvedRoots + } + + /** Whether this deployment has a root locally authored presets go to. */ get authorable(): boolean { - return this.config.roots.some(root => root.trust === 'user') + return this.resolvedRoots.some(root => root.trust === 'user') } /** @@ -358,7 +385,7 @@ export class AgentPresets extends Service { if ((await this.list()).some(preset => preset.id === id)) { throw new PresetExistsError(id) } - await copyComposition(this.config.roots, source, id, name) + await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted // from disk outside `remove`); the new preset must not inherit it. Every // session already joined keeps the generation it runs on regardless. @@ -371,7 +398,7 @@ export class AgentPresets extends Service { * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise { - await deleteComposition(this.config.roots, await this.resolve(id)) + await deleteComposition(this.resolvedRoots, await this.resolve(id)) // Sessions on the deleted preset keep their standing mount; only new // sessions see the roster without it. this.standing.delete(id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index e9240a0b2d..81cfd834ab 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => { ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') const agent = context.agent - if (presets !== undefined && presets.config.roots.length > 0 + if (presets !== undefined && presets.roots.length > 0 && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index b2b48ea6ea..554348cdd6 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,6 +54,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index df69a792d5..8086996111 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,6 +52,10 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], + // Every roster in this file pins its own roots: the derived harness-home + // root would add the developer's real presets to what these assertions + // count, and `copy` would write into it. + includeUserRoot: false, }) }) @@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeUserRoot: false, }) // Writes go to the first user root, so a preset discovered from a later @@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeUserRoot: false, }) expect(readOnly.agentPresets.authorable).toBe(false) @@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeUserRoot: false, }) await fresh.agentPresets.copy('standard', 'mine') diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 709ee5ba00..f73f77a53b 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' -import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets' import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -20,7 +20,7 @@ const ROOTS = [ { path: join(FIXTURES, 'user'), trust: 'user' as const }, ] -async function harness(): Promise { +async function harness(roster: Partial = {}): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx @@ -97,6 +97,28 @@ describe('agent-presets invariants', () => { .rejects.toThrow(/without joining any agent preset/) }) + it('rejects one just the same when the derived home root is the whole roster', async () => { + // The shape this plugin defaults to: an app configures nothing and the + // roster is the harness home alone. A roster is a roster however its roots + // were resolved, so the fail-loud half must not go quiet here — it read + // `config.roots` once, which is empty in exactly this case. + const ctx = await harness({ roots: [], includeUserRoot: true }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('stays silent for a composition that opted out of every root', async () => { + // `includeUserRoot: false` with no configured roots is a deployment that + // mounts the roster but keeps its agents on the host plane; there is no + // roster to join, so an unjoined agent is not a violation. + const ctx = await harness({ roots: [], includeUserRoot: false }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() + }) + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 92a080a930..8901770434 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -528,11 +528,13 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) - it('says nothing when the deployment configures no roster at all', async () => { + it('says nothing when the composition opts out of every root', async () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is - // exactly right there and the diagnostic must stay silent. - const rosterless = await harness({ default: 'standard', roots: [] }) + // exactly right there and the diagnostic must stay silent. Opting out is + // what makes this rosterless — empty `roots` alone would still derive the + // harness-home root, which is a roster like any other. + const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -581,7 +583,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -621,7 +623,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index ef75eb8b78..49f1636a6c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts new file mode 100644 index 0000000000..98c8123d1d --- /dev/null +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -0,0 +1,131 @@ +/** + * The writable root is this package's own, not an assembly fact each app must + * remember: a roster configured with only a `system` root still discovers and + * authors into `/.agent-presets`, the way `dsh-skill-local` owns + * `/skills`. `includeUserRoot: false` is how a deployment — or a test + * pinning an exact roster — opts out. + * + * `$DSH_HOME` is repointed per test because the derived root is resolved in the + * constructor: the plugin must be mounted while the environment names the + * temporary home, or it would reach the developer's real one. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') +/** Spelled out rather than imported: the convention is what these tests assert. */ +const USER_ROOT_SEGMENT = '.agent-presets' +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let home: string +let previousHome: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + includeUserRoot: true, + ...config, + }) + return ctx +} + +/** Hand-place a preset directory under the harness home's preset root. */ +async function seedHomePreset(id: string): Promise { + await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true }) + await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID) +} + +describe('the harness-home preset root', () => { + it('is what a roster gets when config names no roots at all', () => { + // The schema default is the contract an app relies on by saying nothing; + // every other case here passes the field explicitly. The cast stands for + // the untyped document the Loader hands the schema, which is where a + // composition that omits the key actually comes from. + const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) + + expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) + }) + + it('is discovered without any app configuring it', async () => { + await seedHomePreset('mine') + const ctx = await roster() + + const listed = await ctx.agentPresets.list() + + expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) + expect((await ctx.agentPresets.resolve('mine')).path) + .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE)) + }) + + it('makes a roster with only a system root authorable, and receives the copy', async () => { + const ctx = await roster() + + expect(ctx.agentPresets.authorable).toBe(true) + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true) + }) + + it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { + // `standard` exists in the fixture system root; claiming the name at home + // must not take it over, because `copy` refuses an id any root supplies + // and a session resolving `standard` must reach the shipped composition. + await seedHomePreset('standard') + const ctx = await roster() + + expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') + await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) + }) + + it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { + await seedHomePreset('mine') + const ctx = await roster({ includeUserRoot: false }) + + expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') + expect(ctx.agentPresets.authorable).toBe(false) + await expect(ctx.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) + + it('yields to a configured user root for authoring, which writableRoot takes first', async () => { + const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + const ctx = await roster({ + roots: [ + { path: SYSTEM_ROOT, trust: 'system' as const }, + { path: explicit, trust: 'user' as const }, + ], + }) + + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false) + }) +}) diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 7a9c4cd746..b6c343f9a4 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 3db9e83889..47a24fdf0a 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pty-local", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index 37837181fb..6bd31798d8 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pty", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 0074f050b1..63cec95d11 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index 804f6fb1a4..f8dcca8eee 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pty", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 64fdcb8991..1bedb912e2 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 1586df3947..389f23a011 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -32,6 +32,16 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter return { ctx, sandbox } } +/** + * A path inside a fresh temp dir where no file is written, pinning the + * built-entry `existsSync` check to false. Without it the resolution depends on + * whether the checkout has run `build:lib:host`, which emits + * `sandbox-windows-acl/lib/runner.js`. + */ +function absentRunnerEntry(): string { + return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') +} + /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) @@ -395,15 +405,31 @@ describe('the windows-acl probe (runner invocation contract)', () => { }) it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { - // The default probe spawns the exact runner argv confine would use — the - // runner source through tsx on a lib-less checkout. The windows-acl - // runner cannot init off win32, so the probe reads unusable and the walk - // falls through to the injected bwrap verdict on every host. + // No entry injected: this covers the production resolution through + // import.meta.resolve. Which arm of the existsSync check it takes depends + // on whether the checkout has run build:lib:host (which emits + // sandbox-windows-acl/lib/runner.js), so this asserts only what holds + // either way — the runner cannot init off win32, so the probe reads + // unusable and the walk falls through to the injected bwrap verdict. const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) const confined = sandbox.confine(['true'], RO) expect(confined.argv[0]).toBe('bwrap') }, 30_000) + it('falls back to the runner source through tsx when the built entry is absent', async () => { + // The absent entry pins the source-through-tsx arm regardless of build + // state: on a checkout where build:lib:host has run, the real resolution + // above takes the built-entry arm instead and would leave this uncovered. + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: absentRunnerEntry(), + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 3)).toEqual([process.execPath, '--import', 'tsx/esm']) + expect(confined.argv[3]).toMatch(/runner\.ts$/) + }) + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { // windowsAclRunnerInvocation always yields [node, ...] in product; an // override returning [] exercises the default probe's empty-argv guard. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index e5736878e3..1f9ddae514 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index b2f0a2b089..012e162cbb 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index b25c8b74a0..459fb187da 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/schedule/tool-schedule/package.json b/packages/schedule/tool-schedule/package.json index 45ad7e6614..6137239495 100644 --- a/packages/schedule/tool-schedule/package.json +++ b/packages/schedule/tool-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index f767cb1da9..7a84b2dee2 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index b08eb9709e..0a634fcc43 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 72a09f0333..0e22917d1c 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jsonrpc", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index 66fc24d91a..38e3b6e816 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index ba5afe3995..000a4c3db8 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -323,7 +323,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', }, { - signature: 'list(agent: Agent): readonly CommandDescriptor[]', + signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]', jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */', }, { @@ -331,7 +331,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */', }, { - signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', + signature: '@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n *\n * A resolved command\'s lifecycle is logged: `command/run` is appended\n * before the handler is invoked and `command/done` after settlement (a\n * thrown or aborted handler settles as `kind: \'error\'`). Both are direct\n * log-only appends — no turn wraps them, and persistence drains them at\n * ordinary checkpoints. Admission misses (syntax or unknown name) log\n * nothing — they never entered a handler. A `command/run` append failure\n * fails the execution loud; a `command/done` append failure on the\n * handler-failure path is contained so the handler\'s own error stays the\n * reported failure.\n *\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns the settled execution (result + lifecycle pairing id), or\n * `undefined` when syntax or name does not resolve.\n */', }, ], @@ -1384,6 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise', + jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\n */', + }, { signature: 'archiveSession(sessionId: SessionId): Promise', jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', @@ -2266,7 +2270,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationParameterDescriptor', - declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n readonly acceptsUndefined?: true;\n}', }, { name: 'InvocationSourceLocation', diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 156643a8bd..1e503fb7e4 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 4d0e4c29de..7acb2fb2fe 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 6d8ef7696a..4f604788a8 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 751b95d56a..2d9ca797ba 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index aec127fa21..816ab6c087 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 60858d5aa8..9a3a2fec3b 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 33c900a3d8..4ada35f30b 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 59c3cd2a57..1c96d52f48 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index fee23d3970..ee2c2e6b1b 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index d5229618a8..5294d3eab7 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 8dc9a7fb67..5dad6b0d44 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index d333b5d5ca..772efcea95 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-messages-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index f259b41435..0214c1d96c 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-message-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index dff4c3fde3..13b42af03d 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 5ae395c008..577a9c1bc0 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index 5ea2e7a5a9..193893975b 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 2ea49671fc..61d61b2591 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-local", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 08424a08d8..50e6f49518 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index c3c79538c2..43d7828c67 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index c9f3ba8f27..64bdb9d24a 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-local", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index dbb4429d9d..7afb0059bc 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 7712261549..2d3ce92bf4 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 1bab4c650f..9dd973f9ac 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index a18df1647c..0f24085fc7 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index c3379e6f72..f8227a504e 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 064ec4aa8a..dd89801f84 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 78e1d9231a..c62421885a 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 2fef41cebd..ea357a4a0b 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 39c8f0bed3..e5bfa0f6e0 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 5efd65bcd5..1c8901130d 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 4767d1ccb8..7966835ccd 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 17e13287ef..6901a6d20b 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 5d15e3da45..846c00c629 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 85ef8c6d88..734e6c4db1 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 457bd16d36..f42010ae91 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-inprocess", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 28306f0dcc..b4c5d5736e 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index bc25a79c23..f6247d9a6a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -343,7 +343,7 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown inherited tool') + }, {})).rejects.toThrow('unknown global tool') expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 094470807b..41f7dbf0c1 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index fdef892d7f..e3a83942ea 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).rejects.toThrow(/unknown inherited tool "no_such_tool"/) + })).rejects.toThrow(/unknown global tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 8a1bbef19f..6224922e16 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 1a4a1ad4ad..8404f437d4 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 8c2eb5d999..e22ed44505 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 9cd884c016..dd6feb4807 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: d4c472d54f87d6006aef96079050cf14885ed8fb -README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321 +README.md: cc872f3decc4c85f26bd997293c8dc4140eb221c +README.zh.md: f7616dc677d10c141b1be606dae36d7dc7bb23ac diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index d4c472d54f..cc872f3dec 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. -With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The parent is not left guessing when to look, though: the continuation service delivers one settlement notice to it whenever a continuable child's Activation ends, which is why the schema tells the model it will be told and must not poll. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -21,7 +21,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | -| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires the provider's `prepareContinuable` capability and returns a durable child id; it does not require the follow-up tool. | +| `backgroundMode` | Background lifecycle policy, default `one-shot`. `one-shot` defaults calls to foreground; `continuable` defaults them to background, requires the provider's `prepareContinuable` capability, and returns a durable child id without requiring the follow-up tool. | | `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | @@ -37,11 +37,11 @@ Foreground and background calls are concurrency-safe: sibling delegations in one #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation, returns its subagent id, and reports its own completion — so the model is told never to poll or wait on it — while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the task id collected with `task_output` or stopped with `task_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. #### Token effect -Fixed schema cost per parent request; each provider instance adds one schema. +Fixed schema cost per parent request; each provider instance adds one schema, and each continuable instance adds one short system-prompt section. #### KV Cache effect @@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries its closing message, but it is not this call's return value and cannot be awaited here. -- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names. +- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here. +- **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names. - **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 36f79ac879..f7616dc677 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 -设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。不过父级无需猜测何时查看:每当可继续子 agent 的 Activation 结束,继续执行服务都会向父级投递一条结算通知——正因如此,schema 才告诉模型它会被通知,且不得轮询。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,7 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | -| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方具备 `prepareContinuable` 能力并返回持久化子 agent ID;它不要求加载后续消息工具。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`one-shot` 默认前台调用;`continuable` 默认后台调用,要求提供方具备 `prepareContinuable` 能力,并返回持久化子 agent ID,且不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -37,11 +37,11 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话、返回子 agent id 并会自行报告完成的后台子 agent——因此模型被告知绝不要轮询或等待它——而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `task_output` 收集或用 `task_kill` 停止的 task id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 -每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema。 +每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section。 #### KV Cache 影响 @@ -77,6 +77,6 @@ ## 已知限制与暂缓事项 -- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带其收尾消息,但它不是本次调用的返回值,也无法在此等待。 -- **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。 +- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 +- **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`):可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 - **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index b4b912e7db..48d5879ef4 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index b84ee28da9..1e118908c0 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -17,9 +17,13 @@ import type { JsonValue } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-system-prompt' export const name = 'tool-subagent' -export const inject = ['tools', 'subagents'] +export const inject = ['tools', 'subagents', 'systemPrompt'] + +/** Prompt order after bounded delegation policy and before child reporting. */ +const SUBAGENT_SECTION_ORDER = 116.5 /** Config: which registered provider this tool delegates to, plus child defaults. */ export interface Config { @@ -36,9 +40,10 @@ export interface Config { */ enableRunInBackground?: boolean /** - * Background execution policy (default `one-shot`). `continuable` requires a - * provider with the `prepareContinuable` capability and returns the durable - * child id; follow-up adapters remain independently optional. + * Background execution policy (default `one-shot`). `one-shot` defaults calls + * to foreground; `continuable` defaults them to background, requires a provider + * with the `prepareContinuable` capability, and returns the durable child id. + * Follow-up adapters remain independently optional. */ backgroundMode?: 'one-shot' | 'continuable' /** @@ -208,10 +213,10 @@ function providerWording(inheritsConversation: boolean): { description: string; return { description: 'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all ' - + 'completed turns so far (it does not see the current in-flight turn), returning only its final ' - + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, ' + + 'completed turns so far (it does not see the current in-flight turn). Use this when the subtask ' + + 'builds on this conversation\'s context — a follow-up analysis, ' + 'a review, a continuation — without consuming this conversation\'s context for the work itself. ' - + 'You receive only its final answer, not its intermediate steps.', + + 'You receive its result, not its intermediate steps.', promptDescription: 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them ' + 'freely and state only what is new.', @@ -220,9 +225,9 @@ function providerWording(inheritsConversation: boolean): { description: string; return { description: 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) ' - + 'and return its final result. Use this to offload focused, independent work — research, a scoped ' + + 'to offload focused, independent work — research, a scoped ' + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent ' - + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a ' + + 'returns its result, not its intermediate steps. Give it a ' + 'complete, standalone prompt: it does not see this conversation.', promptDescription: 'The complete, self-contained task for the subagent. It does not share this ' @@ -230,6 +235,35 @@ function providerWording(inheritsConversation: boolean): { description: string; } } +interface DelegationRunRequest { + readonly run_in_background?: boolean +} + +interface DelegationRunSpec { + readonly runInBackground: boolean +} + +/** Resolve the model's optional scheduling request into one execution route. */ +function resolveDelegationRun( + request: DelegationRunRequest, + options: { readonly backgroundEnabled: boolean; readonly continuable: boolean }, +): DelegationRunSpec { + if (!options.backgroundEnabled) { + // The validator permits undeclared keys, so schema omission also needs + // execution-time enforcement. + if (request.run_in_background === true) { + throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') + } + return { runInBackground: false } + } + return { + // Continuable work is independently scheduled unless the caller explicitly + // needs the result before its next action. One-shot policy keeps its existing + // foreground default because its background result requires Task collection. + runInBackground: request.run_in_background ?? options.continuable, + } +} + export function apply(ctx: Context, config: Config): void { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). @@ -238,6 +272,9 @@ export function apply(ctx: Context, config: Config): void { if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') } + const backgroundEnabled = config.enableRunInBackground !== false + const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' + const toolName = config.toolName ?? 'subagent' // Mirror provider lifecycle because sibling load order and HMR replacement // can change provider availability while this fiber remains active. let disposeTool: (() => void) | undefined @@ -252,25 +289,21 @@ export function apply(ctx: Context, config: Config): void { ) } const wording = providerWording(provider.inheritsParentContext) - const backgroundEnabled = config.enableRunInBackground !== false - const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' if (continuable && provider.prepareContinuable === undefined) { throw new Error( `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, ) } disposeTool = ctx.tools.register(defineTool({ - name: config.toolName ?? 'subagent', + name: toolName, description: wording.description + (backgroundEnabled // The completion notice is the continuation service's own behavior, not // a separately installed capability, so this promise holds whenever the // continuable background path is reachable at all. ? continuable - ? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:' - + ' this call returns only its subagent id, and the subagent works on its own from there. You' - + ' are told when it finishes, so never poll or wait on it; `send_message` sends it more work.' - : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' - : ''), + ? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.' + : ' This call waits for the result by default. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' + : ' This call waits for the subagent and returns its result.'), parameters: { description: { type: 'string', @@ -286,9 +319,8 @@ export function apply(ctx: Context, config: Config): void { run_in_background: { type: 'boolean' as const, description: continuable - ? 'Run as a background subagent that keeps its conversation and return only its subagent id. ' - + 'This call does not wait for it; you are told when it finishes. Send it more work with send_message.' - : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', + ? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.' + : 'Whether to run as a background task and return its id. Defaults to false; collect with task_output or stop with task_kill.', }, } : {}, }, @@ -352,12 +384,8 @@ export function apply(ctx: Context, config: Config): void { ...maxDepth !== undefined ? { maxDepth } : {}, } - if (args.run_in_background === true) { - // The validator permits undeclared keys, so schema omission also needs - // execution-time enforcement. - if (!backgroundEnabled) { - throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') - } + const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable }) + if (runSpec.runInBackground) { if (continuable) { // Resolves at inbox acceptance: the child owns its own turns from // there, so this call neither waits for nor collects a result. @@ -404,9 +432,11 @@ export function apply(ctx: Context, config: Config): void { } // Register listeners before checking presence so no synchronous change is missed. - // TODO(subagent-dup-toolname): two WAITING fibers configured with the same - // toolName collide when their provider appears, and the duplicate-name throw - // rolls back the provider registration. Add an intent registry if this occurs. + // TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the + // same toolName collide when their provider appears, and the duplicate-name + // throw rolls back the provider registration. Continuable instances reserve + // their prompt-section name during apply() and fail earlier. Add an intent + // registry if the late one-shot collision occurs in a shipped composition. ctx.on('subagent/provider-added', (provider) => { if (provider.name === config.provider && disposeTool === undefined) mount(provider) }) @@ -422,4 +452,16 @@ export function apply(ctx: Context, config: Config): void { // A backend fiber may activate later; a misspelled provider remains visible in this log. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) } + if (backgroundEnabled && continuable) { + // The section follows provider availability without its own manual + // lifecycle: empty text is omitted from rendered prompts while the tool is + // absent, and the registration itself stays owned by this plugin fiber. + ctx.systemPrompt.section({ + name: `tool:${toolName}`, + order: SUBAGENT_SECTION_ORDER, + text: context => disposeTool === undefined || ctx.tools.get(toolName, context.scope) === undefined + ? '' + : `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`, + }) + } } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index c5e1281355..7c233c1924 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -7,7 +7,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import { type Agent } from '@deepseek-ai/dsh-agent' +import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -313,6 +313,22 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toBe('late but fine') }) + it('keeps continuable guidance empty while its provider is absent', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + tool.apply(ctx, { + provider: 'later-continuable', + backgroundMode: 'continuable', + maxDepth: 'provider-managed', + }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(section => section.name === 'tool:subagent')?.text).toBe('') + expect(ctx.tools.schemas().some(schema => schema.name === 'subagent')).toBe(false) + }) + it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -338,13 +354,26 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - // Arm 1: a mounted tool dies with its plugin fiber; the provider survives. - await mock.mountScriptedProvider(ctx, { name: 'mock' }) - const mounted = await ctx.plugin(tool, { provider: 'mock' }) + // Arm 1: a mounted tool and its prompt section die with the plugin fiber; + // the provider survives. + ctx.subagents.registerProvider({ + name: 'continuable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('lifecycle test does not start a child') }, + prepareContinuable: async () => ({}), + }) + const mounted = await ctx.plugin(tool, { + provider: 'continuable', + backgroundMode: 'continuable', + maxDepth: 'provider-managed', + }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:subagent')).toBe(true) await mounted.dispose() expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) - expect(ctx.subagents.getProvider('mock')).toBeDefined() + expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:subagent')).toBe(false) + expect(ctx.subagents.getProvider('continuable')).toBeDefined() // Arm 2: a fiber disposed while WAITING must not react to the provider // arriving later — a surviving listener would re-register a tool that no @@ -563,7 +592,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - // No SubagentService mounted. The tool injects ['tools','subagents'] so its + // No SubagentService mounted. The tool injects its three required services so its // apply never runs; the tool is absent rather than half-registered. let booted = true try { @@ -578,19 +607,19 @@ describe('dsh-tool-subagent', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so + // Postmortem 0001 guard: this plugin HAS an explicit `inject`, so // a stray `export default apply` would collapse the module via // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at // load with "cannot get property … without inject". Guard the shape directly. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent') - expect(tool.inject).toEqual(['tools', 'subagents']) + expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(tool) as Record expect(unwrapped).toBe(tool) expect(unwrapped.name).toBe('tool-subagent') - expect(unwrapped.inject).toEqual(['tools', 'subagents']) + expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt']) expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) @@ -985,21 +1014,31 @@ describe('dsh-tool-subagent continuable background mode', () => { signal: testToolSignal, callId: CallId('subagent-continuable'), name: 'subagent', - arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, + arguments: { description: 'do work', prompt: 'Reply OK' }, })).toEqual({ kind: 'parallel' }) }) - it('starts a continuable child and returns only its durable id, creating no Task', async () => { + it('defaults continuable delegation to background and returns only its durable id', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! // Continuable delegation has no Task, so the schema promises no collection. expect(schema.description).not.toContain('task_output') expect(schema.description).not.toContain('task_kill') expect(schema.description).toContain('send_message') + expect(schema.description).toContain('runs in the background by default') + expect(schema.description).not.toContain('never poll or wait on it') + const properties = (schema.parameters as { + properties: Record + }).properties + expect(properties.run_in_background?.description).toContain('Defaults to true') + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(parent)) + const guidance = assembly.sections.find(section => section.name === 'tool:subagent') + expect(guidance?.text).toContain('Use subagent in the background by default') + expect(guidance?.text).toContain('runtime sends you a notice containing its outcome') const started = await callSubagent( ctx, - { description: 'continuable work', prompt: 'dig in', run_in_background: true }, + { description: 'continuable work', prompt: 'dig in' }, { agent: parent }, ) expect(started.isError).toBe(false) @@ -1018,6 +1057,29 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) + it('hides continuable guidance when the current agent cannot see the tool', async () => { + const { ctx, parent } = await continuableSetup() + parent.ctx.tools.restrict({ deny: ['subagent'] }) + + expect(ctx.tools.get('subagent', parent)).toBeUndefined() + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(parent)) + expect(assembly.sections.find(section => section.name === 'tool:subagent')?.text).toBe('') + }) + + it('waits for a continuable provider only when run_in_background is explicitly false', async () => { + const { ctx, parent } = await continuableSetup() + const result = await callSubagent( + ctx, + { description: 'blocking work', prompt: 'dig in', run_in_background: false }, + { agent: parent }, + ) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected foreground subagent success') + expect(result.value).toMatchObject({ kind: 'foreground' }) + expect(text(result)).toBe('continuable answer') + expect(ctx.tasks.list(parent)).toEqual([]) + }) + it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { const { ctx, parent } = await continuableSetup() const bothPreparing = Promise.withResolvers() diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 25780c367f..455ebbf3ac 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/tools" }, + { + "path": "../../core/system-prompt" + }, { "path": "../subagent" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..bc67b9369c 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 55558024f4..0769654faf 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index e06a890806..c701ebd247 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 43c49f3953..138d4da298 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 1d01c18bd0..5001e61ac6 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 203b092987..73ac097b1a 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 3c05e7fbf6..8d16d418fe 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 9f43ca3a01..4f402f40e6 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index cd40629948..67c851607e 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tasks-local", "description": "Process-local implementation of the DeepSeek Harness background task registry seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 8c968b743b..97dd504c98 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tasks", "description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index c1ab0be9c4..92c25c45dd 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-tasks", "description": "Model-facing background task control tools (task_output, task_list, task_kill) over the ctx.tasks registry", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 0f080c5d33..a9d2659150 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 53e711b3a7..a3180f190f 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 358edceb66..c922232fe7 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -983,8 +983,8 @@ class FaceAnalyzer { } if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') - if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const optional = parameter.questionToken !== undefined const authoredType = this.requiredType(parameter, parameter.type, 'parameter') const cancellationName = parameter.name.text === 'signal' const cancellationType = this.isGlobalAbortSignal(authoredType) @@ -1002,6 +1002,7 @@ class FaceAnalyzer { const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel if (lookup !== undefined) { + if (optional) this.fail(parameter, `lookup parameter for ${lookup.key} cannot be optional`) if (parameter.name.text !== lookup.key) { this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) } @@ -1025,10 +1026,13 @@ class FaceAnalyzer { name: parameter.name.text, wire: parameter.name.text, source: 'json', + ...optional ? { optional: true as const } : {}, boundary: this.remoteBoundary( authoredType, `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, false, + 'undefined', + optional, ), } } @@ -1095,6 +1099,7 @@ class FaceAnalyzer { resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, + 'undefined-or-void', ), location: this.location(method.name), } @@ -1336,9 +1341,18 @@ class FaceAnalyzer { authoredType: ts.TypeNode, fallbackTypeSymbol: string, requireNamed: boolean, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', + optional = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) - const codecType = this.resolvedRemoteCodecType(authoredType) + const declaredType = this.checker.getTypeFromTypeNode(authoredType) + // An optional parameter's authored node carries no `undefined`; the codec + // still has to accept the omitted wire field the consumer sends. + const resolvedType = optional + ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) + : declaredType + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() const visit = (node: ts.Node): void => { @@ -1365,6 +1379,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: `${imported.specifier}#${imported.name}`, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1374,6 +1389,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: fallbackTypeSymbol, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1387,9 +1403,18 @@ class FaceAnalyzer { * validated without teaching the compiler-independent emitter TypeScript's * type evaluator. */ - private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { - const resolvedType = this.checker.getTypeFromTypeNode(authoredType) - this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) + private resolvedRemoteCodecType( + authoredType: ts.TypeNode, + resolvedType: ts.Type, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + ): TypeNodeId { + this.assertRemoteJsonType( + resolvedType, + authoredType, + new Set(), + topLevelAbsence !== 'reject', + topLevelAbsence === 'undefined-or-void', + ) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1554,9 +1579,11 @@ class FaceAnalyzer { site: ts.TypeNode, active: Set, allowUndefined: boolean, + allowVoid: boolean, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & ts.TypeFlags.Void) !== 0 && allowVoid) return if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) } @@ -1569,13 +1596,15 @@ class FaceAnalyzer { | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return if (type.isUnion()) { - for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + for (const member of type.types) { + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + } return } if (type.isIntersection()) { const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') - for (const member of material) this.assertRemoteJsonType(member, site, active, false) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -1606,6 +1635,7 @@ class FaceAnalyzer { site, active, (elementFlags & ts.ElementFlags.Optional) !== 0, + false, ) }) return @@ -1613,7 +1643,7 @@ class FaceAnalyzer { if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) if (element === undefined) this.fail(site, 'Remote boundary array has no element type') - this.assertRemoteJsonType(element, site, active, false) + this.assertRemoteJsonType(element, site, active, false, false) return } const properties = this.checker.getPropertiesOfType(type) @@ -1628,19 +1658,25 @@ class FaceAnalyzer { site, active, (property.flags & ts.SymbolFlags.Optional) !== 0, + false, ) } for (const info of this.checker.getIndexInfosOfType(type)) { if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { this.fail(site, 'Remote boundary contains a symbol index signature') } - this.assertRemoteJsonType(info.type, site, active, false) + this.assertRemoteJsonType(info.type, site, active, false, false) } } finally { active.delete(type) } } + private includesRemoteAbsence(type: ts.Type): boolean { + if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true + return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member)) + } + private isRemotePhantomConstraint(type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index cbed0047c3..03dcc30d36 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -308,6 +308,7 @@ export class FaceModelEmitter { lines.push(` wire: ${quote(parameter.wire)},`) lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') lines.push(` codec: ${indent(strictCodec( parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index)), @@ -342,6 +343,7 @@ export class FaceModelEmitter { const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', + ' RemoteResult,', ' TypeRTRemoteContribution,', '} from \'@deepseek-ai/dsh-type-meta\'', ] @@ -462,10 +464,13 @@ export class FaceModelEmitter { ): string { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => - `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) - return `(${parameters.join(', ')}) => Promise<${result}>` + // The Client Remote face delivers the carrier's outcome, so every generated + // consumer signature resolves to a result the caller reads instead of a + // value it must guard with its own try/catch. + return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 81bc6a91a1..40eb31dde8 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -107,6 +107,8 @@ export interface RemoteBoundaryModel { readonly type: TypeNodeId /** Checker-resolved projection used only to emit the runtime codec. */ readonly codecType: TypeNodeId + /** Whether the authored top-level boundary explicitly accepts `undefined`. */ + readonly acceptsUndefined: boolean readonly typeSymbol: string readonly imports: readonly RemoteTypeImportModel[] } @@ -117,6 +119,8 @@ export interface InvocationParameterModel { readonly wire: string readonly source: 'json' | 'lookup' readonly lookup?: string + /** Authored as an optional parameter, so consumers may omit the wire field. */ + readonly optional?: true readonly boundary: RemoteBoundaryModel } diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 4a303c4bd4..94d1d937e4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -98,10 +98,12 @@ export class WorkspaceTypertGenerator { const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' ? (manifest.exports as Record)['./remote'] : undefined + // The declaration map is emitted beside these two but never published: it + // serves editor navigation in the workspace, where the package link + // resolves its source. const remoteFiles = [ 'lib/typert.remote-client.js', 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', ] if (artifact.remote === undefined) { if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) { diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json index b7e0631a0a..bfd57762df 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json @@ -18,7 +18,6 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map" + "lib/typert.remote-client.d.ts" ] } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 707dc84ce9..7fbc5bd656 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -13,6 +13,16 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTRemoteMap {} export interface TypeRTRemoteScopeMap {} + export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object + } + + export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` ? Method diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 3faef9c8be..443d5997c3 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -23,6 +23,7 @@ interface RuntimeDescriptor { readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string + readonly acceptsUndefined?: true readonly codec: { readonly schema: RuntimeSchema } }[] readonly result: { readonly schema: RuntimeSchema } @@ -113,15 +114,15 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain( - "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) const remoteJs = artifact?.remote?.js @@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) }) + it('projects authored optionality and absence onto consumers and codecs', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', + ` + + @Remote + maybe(value: string | undefined): string | undefined { + return value + } + + @Remote + labelled(id: string, label?: string): string { + return label ?? id + } + + @Remote + clear(): void {} +} + +export type {`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/maybe': (value: string | undefined) => Promise>", + ) + expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise>") + // An explicit `T | undefined` stays a required argument; only authored + // optionality lets a consumer omit the field. + expect(artifact?.remote?.dts).not.toContain('value?: string') + expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise>") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe')) + const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear')) + expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true) + expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(maybe?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(null).success).toBe(false) + const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled')) + expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined() + expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -204,7 +256,7 @@ export type GenericResult = { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - "'goals/dispatch': (request: GenericRequest) => Promise", + "'goals/dispatch': (request: GenericRequest) => Promise>", ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') @@ -254,7 +306,7 @@ export interface BoxPayload { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) - expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>>') assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -274,7 +326,7 @@ export interface BoxPayload { )) const [artifact] = new WorkspaceTypertGenerator(root).generate() - expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise>") assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -436,9 +488,9 @@ export interface ClientMarker { message: 'Remote parameters cannot have default values', }, { - name: 'optional parameter', - edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), - message: 'Remote parameters cannot be optional', + name: 'optional lookup parameter', + edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'), + message: 'lookup parameter for agent cannot be optional', }, { name: 'wrong cancellation type', @@ -584,6 +636,7 @@ function assertRemoteConsumerTypechecks( const consumerSource = ` import remote from '@fixture/remote/remote' import type { + RemoteResult, TypeRTRemoteContribution, TypeRTRemoteScopeMap, TypeRTRemoteMap, @@ -595,12 +648,12 @@ const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] -const created: Promise = create('agent-1', { title: 'ship' }) -const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) -const createdScoped: Promise = createScoped({ title: 'ship' }) -const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +const created: Promise> = create('agent-1', { title: 'ship' }) +const cancellable: Promise> = create('agent-1', { title: 'ship' }, new AbortController().signal) +const createdScoped: Promise> = createScoped({ title: 'ship' }) +const renamed: Promise> = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { remote: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) +const navigated: Promise> = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index ec66e2165b..46cd186a1e 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 0a75703929..45fba63e5e 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index b8bab3a121..06e2161a1e 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -653,6 +653,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } wires.add(parameter.wire) if (parameter.source === 'lookup') { + if (parameter.acceptsUndefined !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" cannot accept undefined`) + } if (parameter.lookup === undefined) { throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 1bc4b65cb1..69e6497c2e 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -508,6 +508,17 @@ describe('TypertRegistry', () => { ...invocation(), parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + acceptsUndefined: true, + codec: { mode: 'src-json' }, + }], + }, 'cannot accept undefined'], [{ ...invocation(), parameters: [{ diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 3195392acb..6f848885c9 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-type-meta", "description": "Compiler-independent Remote metadata and TypeRT provider protocols", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3becc67c83..7bbf3f107a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,8 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteFailure, + RemoteResult, TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c08fb209ee..9fffb8a468 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -39,6 +39,28 @@ export interface TypeRTContextMap {} /** Merge-extensible direct Remote method signatures generated for consumers. */ export interface TypeRTRemoteMap {} +/** + * One Remote call's failure as the carrier reported it. `code` stays open here: + * the closed RPC code union belongs to the carrier package, which already + * depends on this one, so naming it would invert that edge. + */ +export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** + * What every generated Remote method resolves to. The Remote face itself folds + * carrier failures into the error branch, so no consumer wraps a call to + * recover one; only assembly faults (arity, an unmounted method, a missing + * Context binder) still reject. + * @template T - the Host method's business result. + */ +export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypeRTRemoteScopeMap {} @@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor { readonly lookup?: string /** Boundary codec for the wire representation. */ readonly codec: TypeRTCodec + /** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */ + readonly acceptsUndefined?: true } /** Source position retained for diagnostics from generated definitions. */ diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index a26a5e62d3..ad63c689dc 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 1496dfdb3a..c771e1fee3 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 6fa44ab20c..bdb01c8f43 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index af0891fb82..d4a2044f82 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index e3c492ea34..07683e298b 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index eea04fee3e..1d563f34b8 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 72daaa2c6b..aeaac1eb9b 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 0135e83403..0f72197514 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 562f8be8f6..9a79f66a83 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-local", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 532b9d14bd..f51ec01a60 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 55b18bec50..e8d12fed01 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 09c3b823ae..a1bac036f6 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 21e9811c1e..151a2133cc 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index f92eb944c4..d45a9ba985 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 0b650b150c..effd74b6e2 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index af20d74fb2..08a8739242 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-workerthread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index c1391b7f24..6f7ddc13fd 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 63d5ce0e8e..caeadcc3d8 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: 057765e38de9cc700210eb8edeb1ddc7ffc861ff -README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7 +README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254 +README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 057765e38d..4f7e2925ca 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes. +- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry Workspace order never changes. - `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 7416875dbf..2f4f38dea8 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。 +- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 4ab1aeecbe..67b6921d4a 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspace): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d972085939..5d1f3296d8 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** A workspace reorder named a source or anchor absent from the durable registry order. */ +export class WorkspaceOrderInvalidError extends Error { + /** + * @param workspaceId - Missing source or anchor id. + */ + constructor(readonly workspaceId: WorkspaceId) { + super(`cannot reorder unknown workspace '${workspaceId}'`) + this.name = 'WorkspaceOrderInvalidError' + } +} + declare module '@deepseek-ai/cordis' { interface Context { @@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ + insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id) + if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) { + throw new WorkspaceOrderInvalidError(beforeId) + } + if (beforeId === id) return state.workspaceIds + const without = state.workspaceIds.filter(workspaceId => workspaceId !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)] + if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds + await this.setState({ ...state, workspaceIds }) + return workspaceIds + }) + } + /** * The registry-global archive set: sessions hidden from every grouping * surface. Archiving never touches workspace accounting — an archived diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 3c4b6185fb..c04980eecd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts' +import WorkspaceRegistry, { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, +} from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => { }) }) +describe('Workspace registry ordering', () => { + it('moves a workspace before an anchor or to the end and restores that order after restart', async () => { + const firstDir = await makeDir('order-first') + const secondDir = await makeDir('order-second') + const thirdDir = await makeDir('order-third') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const third = await result.registry.create(thirdDir) + expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id]) + + await expect(result.registry.insertBefore(first.id, second.id)) + .resolves.toEqual([third.id, first.id, second.id]) + await expect(result.registry.insertBefore(third.id)) + .resolves.toEqual([first.id, second.id, third.id]) + expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id]) + + const restarted = await harness({ pool: result.pool }) + expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id]) + }) + + it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => { + const firstDir = await makeDir('order-noop-first') + const secondDir = await makeDir('order-noop-second') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const written = result.changes.length + + await result.registry.insertBefore(second.id, second.id) + await result.registry.insertBefore(second.id, first.id) + await result.registry.insertBefore(first.id) + expect(result.changes).toHaveLength(written) + expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id]) + + await expect(result.registry.insertBefore(WorkspaceId('missing'))) + .rejects.toBeInstanceOf(WorkspaceOrderInvalidError) + await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor'))) + .rejects.toMatchObject({ workspaceId: 'missing-anchor' }) + expect(result.changes).toHaveLength(written) + }) +}) + describe('Workspace session ordering', () => { it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18a0fcd55c..2e71c12536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,9 +210,6 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference - '@deepseek-ai/dsh-time-context': - specifier: workspace:^ - version: link:../../packages/context/time-context '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -222,6 +219,9 @@ importers: '@deepseek-ai/dsh-tasks-local': specifier: workspace:^ version: link:../../packages/tasks/tasks-local + '@deepseek-ai/dsh-time-context': + specifier: workspace:^ + version: link:../../packages/context/time-context '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context @@ -1566,6 +1566,12 @@ importers: '@deepseek-ai/dsh-client-ui-deliverables': specifier: workspace:^ version: link:../../client/ui-deliverables + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal @@ -1862,9 +1868,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../../api/gateway + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2049,6 +2055,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2190,6 +2199,79 @@ importers: specifier: ~18.3.1 version: 18.3.31 + packages/client/ui-directory-picker: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-directory-picker-native: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': @@ -2417,9 +2499,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2606,6 +2688,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2626,19 +2711,17 @@ importers: version: 18.3.31 packages/client/ui-settings: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../../api/gateway '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2988,6 +3071,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -4777,6 +4863,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -4801,40 +4893,13 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../client/test-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/host/directory-picker-native: dependencies: @@ -4851,24 +4916,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 tsx: specifier: ^4.19.2 version: 4.22.4 @@ -4906,6 +4956,10 @@ importers: version: link:../../support/invariants packages/interaction/commands: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4925,6 +4979,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta packages/interaction/permission: dependencies: @@ -5725,6 +5782,48 @@ importers: specifier: workspace:^ version: link:../sandbox-local + packages/schedule/tool-schedule: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/sdk/client: devDependencies: '@deepseek-ai/cordis': @@ -5804,48 +5903,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/schedule/tool-schedule: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../support/agent-loop-testkit - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/self-modification/tool-cordis: dependencies: '@deepseek-ai/schemastery': diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 64fd43867a..42aa01ec6e 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 0f91f3d9b5e770852a0c1e638b1506a14b8d65c0 -README.zh.md: a3adb241f5d27b49ecab2f1541dfd9775a34a24f +README.md: 70b9d6391644d10ee7d5c29ce122632786e3bbcc +README.zh.md: 1d1a23576cc8029dacbb2df0e3d1d9fc2ce27426 diff --git a/python/sdk/README.md b/python/sdk/README.md index 0f91f3d9b5..70b9d63916 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -40,7 +40,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. +The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`jsonrpc-agent` example](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md) owns the complete standalone Cordis file used there. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index a3adb241f5..1d1a23576c 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -37,7 +37,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 +[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)提供不使用 Web UI 的顺序安装与首次运行路径。[`jsonrpc-agent` 示例](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md)归属该教程使用的完整独立 Cordis 文件。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。两个结果字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0036dc2929..263781ce5a 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -53,7 +53,9 @@ const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[ const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js', 'config'], - '@deepseek-ai/dsh-frontend': ['dist'], + // The Web build emits sourcemaps for browser debugging; publishing them is + // what the payload policy forbids, so the bundle ships without them. + '@deepseek-ai/dsh-frontend': ['dist', '!dist/**/*.map'], } /** The subset of package.json fields this constraint check cares about. */ @@ -129,9 +131,8 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib; - // dsh-base also ships the win32 shell platform layer the launcher reads. - '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], @@ -152,7 +153,6 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] - const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -185,13 +185,8 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] : [], - ...typeRTRemoteNavigation - ? [ - 'lib/typert.remote-client.js', - 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', - 'src', - ] + ...hasTypeRTRemoteNavigation(manifest) + ? ['lib/typert.remote-client.js', 'lib/typert.remote-client.d.ts'] : [], ] } @@ -270,9 +265,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.name?.startsWith('@deepseek-ai/')) { const allowedSources = publicationSourceAllowlist[manifest.name] ?? [] - const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file, publicationPolicy) && !allowedSources.includes(file)) { + if (isForbiddenPublicationFile(file) && !allowedSources.includes(file)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index ffd7af67be..e892df5471 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -436,7 +436,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', source: 'packages/subagent/tool-subagent/src/index.ts', - requires: ['ctx.tools', 'ctx.subagents'], + requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'], shippedNames: ['subagent', 'subagent_fork'], async mount(ctx) { @@ -445,7 +445,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance\'s description and `run_in_background` parameter follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable`, while `subagent_fork` stays `one-shot` — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance\'s description, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 5126fe0986..d9308e2276 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -53,7 +53,9 @@ describe('Oxlint executable contract', () => { ['host package source', 'packages/fs/fs-policy/src', 'packages/fs/fs-policy/tsconfig.json'], ['host package test', 'packages/fs/fs-policy/tests', 'tsconfig.host.json'], ['client package source', 'packages/client/ui-primitives/src', 'packages/client/ui-primitives/tsconfig.json'], - ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json'], + // A test under packages/client states its face in the filename, so the + // probe carries the Client suffix to reach the Client aggregate. + ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json', '.client.ts'], ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], ['website', 'website', 'tsconfig.host.json'], ] as const @@ -66,8 +68,8 @@ probePromise() try { const paths: Array = [] - for (const [label, parent, tsconfig] of probes) { - const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`) + for (const [label, parent, tsconfig, extension = '.ts'] of probes) { + const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`) await writeFile(path, source) paths.push([label, relative(repositoryRoot, path), tsconfig]) } @@ -98,7 +100,8 @@ probePromise() expect(output).not.toContain('Unmatched file:') } finally { await Promise.all([ - ...probes.map(([, parent]) => rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`), { force: true })), + ...probes.map(([, parent, , extension = '.ts']) => + rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`), { force: true })), rm(configPath, { force: true }), ]) } diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts index 03603ef54d..11db34ed55 100644 --- a/scripts/publication-payload.spec.ts +++ b/scripts/publication-payload.spec.ts @@ -29,6 +29,9 @@ describe('publication payload policy', () => { String.raw`src\index.ts`, 'lib/types/index.d.ts.map', './lib/types/index.d.ts.map', + 'lib/typert.remote-client.d.ts.map', + 'lib/client.js.map', + './lib/client.js.map', ])('rejects static manifest path %s', (file) => { expect(isForbiddenPublicationFile(file)).toBe(true) }) @@ -40,11 +43,19 @@ describe('publication payload policy', () => { ])).toThrow('fixture.tgz publishes source file package/src/index.ts') }) - it('rejects declaration maps in packed tarballs', () => { + it('rejects source maps in packed tarballs', () => { expect(validateFixtureTarball([ 'package/package.json', 'package/lib/types/index.d.ts.map', - ])).toThrow('fixture.tgz publishes declaration map package/lib/types/index.d.ts.map') + ])).toThrow('fixture.tgz publishes source map package/lib/types/index.d.ts.map') + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/typert.remote-client.d.ts.map', + ])).toThrow('fixture.tgz publishes source map package/lib/typert.remote-client.d.ts.map') + expect(validateFixtureTarball([ + 'package/package.json', + 'package/lib/client.js.map', + ])).toThrow('fixture.tgz publishes source map package/lib/client.js.map') }) it('accepts a clean packed tarball', () => { @@ -56,19 +67,6 @@ describe('publication payload policy', () => { ])).not.toThrow() }) - it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { - const policy = { typeRTRemoteNavigation: true } - expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) - expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) - expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) - expect(() => { - validateTarballPayload([ - 'package/lib/typert.remote-client.d.ts.map', - 'package/src/index.ts', - ], 'fixture.tgz', policy) - }).not.toThrow() - }) - it('recognizes only the canonical Host-for-Client export pair', () => { expect(hasTypeRTRemoteNavigation({ exports: { diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 60f37b4f94..a6b4f6bebf 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,11 +1,10 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ -/** Publication exceptions required for TypeRT declaration-map navigation. */ -export interface PublicationPayloadPolicy { - readonly typeRTRemoteNavigation?: boolean -} - -/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +/** + * Whether a package manifest exports generated Host-for-Client metadata. + * @param manifest - parsed package manifest to inspect. + * @returns whether the canonical `./remote` export pair is present. + */ export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false const exportsField = (manifest as Record).exports @@ -23,35 +22,34 @@ function payloadPath(file: string): string { return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized } -/** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile( - file: string, - policy: PublicationPayloadPolicy = {}, -): boolean { +/** + * Whether a package payload path exposes source or map intermediates. Maps + * serve editor navigation during development, where a workspace consumer + * resolves their source through the package link; a published map resolves + * nothing, so no payload publishes one. + * @param file - manifest path or tarball member to classify. + * @returns whether publishing this path is forbidden. + */ +export function isForbiddenPublicationFile(file: string): boolean { const normalized = payloadPath(file) - if (policy.typeRTRemoteNavigation === true - && (normalized === 'src' - || normalized.startsWith('src/') - || normalized === 'lib/typert.remote-client.d.ts.map')) { - return false - } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') + || normalized.endsWith('.js.map') } -/** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload( - files: readonly string[], - context: string, - policy: PublicationPayloadPolicy = {}, -): void { +/** + * Reject source and map members in a packed npm tarball. + * @param files - tarball members to validate. + * @param context - tarball identity named in the failure. + */ +export function validateTarballPayload(files: readonly string[], context: string): void { for (const file of files) { - if (!isForbiddenPublicationFile(file, policy)) continue + if (!isForbiddenPublicationFile(file)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) } - throw new Error(`${context} publishes declaration map ${file}`) + throw new Error(`${context} publishes source map ${file}`) } } diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 320f11997f..d69923910f 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' +import { validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -323,9 +323,7 @@ class ReleaseBundle { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } if (expected.origin === 'harness') { - validateTarballPayload(artifact.files, tarball, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), - }) + validateTarballPayload(artifact.files, tarball) } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) @@ -401,9 +399,7 @@ class ReleaseBundle { } const artifact = inspectTarball(path, runner) if (pkg.origin === 'harness') { - validateTarballPayload(artifact.files, pkg.tarball, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), - }) + validateTarballPayload(artifact.files, pkg.tarball) } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index e4c5fda5be..d39552636d 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -11,7 +11,7 @@ import { globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts' +import { validateTarballPayload } from '../publication-payload.ts' /** Dependency sections that constrain publish order: a consumer must publish after its dependency. */ const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const @@ -225,9 +225,7 @@ class DshFamily extends ReleaseFamily { * @param files - every path inside its tarball. */ validatePayload(member: ReleaseMember, files: readonly string[]): void { - validateTarballPayload(files, member.name, { - typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest), - }) + validateTarballPayload(files, member.name) } readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' } diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index d2092b1d0c..fc00b3ea9e 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -89,9 +89,9 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section.client.spec.tsx', upstream: ['cordis'] }, { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, @@ -99,6 +99,8 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // the preset a model mounts, so the scoped name would send the model after an // id no roster reports. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + // The preset-roster loop names the `cordis` preset id, not a package. + { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, @@ -126,7 +128,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, @@ -323,7 +325,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ { // The real package references in files whose other `cordis` strings are preset ids. id: 'agent-preset-spec-framework-import', - file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', + file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', find: "import { Context } from 'cordis'", replace: "import { Context } from '@deepseek-ai/cordis'", expect: 1, diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 74c19b175e..538714fa5e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index be550a580b..35dbb978dd 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -304,7 +304,7 @@ { "doc": "docs/subsystems/commands.md", "symbol": "CommandInputDescriptor", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", @@ -319,12 +319,12 @@ { "doc": "docs/subsystems/commands.md", "symbol": "CommandResult", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", "symbol": "CommandDescriptor", - "source": "packages/interaction/commands/src/index.ts" + "source": "packages/interaction/commands/src/types.ts" }, { "doc": "docs/subsystems/commands.md", diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts new file mode 100644 index 0000000000..6c1304e16a --- /dev/null +++ b/scripts/verify-cordis-config.spec.ts @@ -0,0 +1,39 @@ +/** + * The verify-cordis-config metadata contract: `disabled` is the one entry + * metadata field whose `!!js` expression the Loader interpolates; every other + * metadata field must stay static, and a disabled expression must parse. + */ + +import { describe, expect, it } from 'vitest' +import { metadataExpressionErrors } from './verify-cordis-config.ts' + +describe('verify-cordis-config metadata expressions', () => { + it('accepts a disabled !!js expression', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: { __jsExpr: "process.platform === 'win32'" } }, + '[0]', + ) + expect(problems).toEqual([]) + }) + + it('rejects an expression in a static metadata field', () => { + const problems = metadataExpressionErrors({ id: { __jsExpr: 'process.platform' }, name: 'pkg' }, '[0]') + expect(problems).toContain('[0].id: !!js is not interpolated here') + }) + + it('rejects an expression nested below disabled (only the field itself interpolates)', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: 'pkg', disabled: { when: { __jsExpr: 'process.platform' } } }, + '[0]', + ) + expect(problems).toContain('[0].disabled.when: !!js is not interpolated here') + }) + + it('rejects a disabled expression that does not parse (the loader would fail the boot)', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: 'pkg', disabled: { __jsExpr: 'process.platform ===' } }, + '[0]', + ) + expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true) + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index d834094d78..bb10db0c70 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,11 +1,13 @@ /** * Validate Cordis Loader entry metadata and package resolution. * - * The Loader interpolates only a plugin entry's `config`; expression objects in - * fields such as `disabled` remain truthy data and silently change composition. - * Example configs and the dsh Web composition resolve named plugins from their - * owning workspace manifests. Local example packages must also be in the root - * TypeScript project graph. + * The Loader interpolates a plugin entry's `config` (after declared injections + * activate, against that plugin context) and the entry `disabled` field (at + * every mount decision, against the loader context). Every other entry + * metadata field stays static, so an expression there remains truthy data and + * silently changes composition. Example configs and the dsh Web composition + * resolve named plugins from their owning workspace manifests. Local example + * packages must also be in the root TypeScript project graph. */ import { globSync, readFileSync } from 'node:fs' @@ -36,20 +38,23 @@ const appOverlayFiles = new Set([ 'examples/web-schedule/cordis.yml', ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }), ]) -const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const +const metadataFields = ['id', 'name', 'group', 'inject', 'intercept', 'isolate'] as const /** The adaptive directory-picker chooser package (mounts a backend row at boot). */ const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' /** - * The backends the chooser mounts by runtime string (mirror of its exported - * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting - * the chooser must resolve both, or keyless Linux CI (which only ever - * resolves `browse`) hides a dropped `-native` dependency until a macOS boot. + * The packages the chooser mounts by runtime string (mirror of its exported + * `BACKEND_PACKAGES` and `SURFACE_PACKAGES`), invisible to yml-row scanning: a + * composition mounting the chooser must resolve every one, or keyless Linux CI + * (which only ever resolves `browse`) hides a dropped `-native` dependency + * until a macOS boot. */ const CHOOSER_BACKEND_PACKAGES = [ '@deepseek-ai/dsh-host-directory-picker-native', '@deepseek-ai/dsh-host-directory-picker-browse', + '@deepseek-ai/dsh-client-ui-directory-picker', + '@deepseek-ai/dsh-client-ui-directory-picker-native', ] const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', @@ -61,33 +66,36 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -const files = cordisConfigFiles(root) const errors: string[] = [] const pluginReferences: PluginReference[] = [] -for (const file of files) { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) - if (!isUnknownArray(document)) { - errors.push(`${file}: root must be a Loader entry array`) - continue - } - for (let index = 0; index < document.length; index++) { - validateEntry(document[index], file, `[${index}]`) - } -} +if (import.meta.main) { + const files = cordisConfigFiles(root) -errors.push(...validateExampleResolution()) -errors.push(...validateAppResolution()) -errors.push(...validateSourcePlaneResolution()) -errors.push(...validatePresetPlaneSeparation()) -errors.push(...validateClientHalvesDeclared()) + for (const file of files) { + const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + if (!isUnknownArray(document)) { + errors.push(`${file}: root must be a Loader entry array`) + continue + } + for (let index = 0; index < document.length; index++) { + validateEntry(document[index], file, `[${index}]`) + } + } -if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') - for (const error of errors) console.error(`- ${error}`) - process.exitCode = 1 -} else { - console.log(`verify-cordis-config: ${files.length} config files passed.`) + errors.push(...validateExampleResolution()) + errors.push(...validateAppResolution()) + errors.push(...validateSourcePlaneResolution()) + errors.push(...validatePresetPlaneSeparation()) + errors.push(...validateClientHalvesDeclared()) + + if (errors.length > 0) { + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') + for (const error of errors) console.error(`- ${error}`) + process.exitCode = 1 + } else { + console.log(`verify-cordis-config: ${files.length} config files passed.`) + } } /** @@ -406,11 +414,60 @@ function packageNameFromSpecifier(specifier: string): string | undefined { } function validateMetadata(entry: Record, file: string, path: string): void { + for (const problem of metadataExpressionErrors(entry, path)) { + errors.push(`${file}${problem}`) + } +} + +/** + * Expression-node diagnostics for one entry. `disabled` is the single + * interpolated metadata field: its own `!!js` expression node is allowed and + * must parse, while expressions nested below it stay truthy data; every other + * metadata field must stay fully static. + * @param entry - one loader entry (or patch row). + * @param path - the entry's diagnostic path prefix. + * @returns one diagnostic per offending expression. + */ +export function metadataExpressionErrors(entry: Record, path: string): string[] { + const problems: string[] = [] for (const field of metadataFields) { if (!(field in entry)) continue const expressionPaths: string[] = [] collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths) - for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`) + for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`) + } + const disabled = entry.disabled + if (disabled !== undefined) { + if (isJsExpr(disabled)) { + const detail = disabledExpressionProblem(disabled.__jsExpr) + if (detail !== undefined) problems.push(`${path}.disabled${detail}`) + } else { + // A non-expression value gates on Boolean() at mount; an expression + // nested anywhere below it never evaluates, so it must stay literal. + const expressionPaths: string[] = [] + collectExpressionPaths(disabled, `${path}.disabled`, expressionPaths) + for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`) + } + } + return problems +} + +/** + * Parse-only validation of a `disabled` expression: the Loader evaluates it + * at every mount decision, and a syntax error would fail the boot — rejecting + * it here moves that failure to the earliest resolvable point. + * @param expression - the `!!js` expression text. + * @returns the diagnostic suffix, or `undefined` when the expression parses. + */ +function disabledExpressionProblem(expression: string): string | undefined { + try { + // Compilation only — the constructor never executes the body. + // oxlint-disable-next-line typescript/no-implied-eval + new Function(`return (${expression})`) + return undefined + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + return `: disabled expression does not parse: ${detail}` } } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4976b3b1f1..b11a8845e8 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -85,6 +85,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, diff --git a/tsconfig.client.json b/tsconfig.client.json index a054cb8751..c956867b7a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,24 +19,25 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", - "packages/host/directory-picker-browse/tests/**/*.ts", - "packages/host/directory-picker-browse/tests/**/*.tsx", - "packages/host/directory-picker-native/tests/**/*.ts", - "packages/host/directory-picker-native/tests/**/*.tsx", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.ts", "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], + // A `*.host.spec.ts` covers the Host half of a split client package and + // belongs to the host aggregate, which excludes this program's `*.client.*` + // in turn. `exclude` wins over `include`, so the test glob above stays broad. + "exclude": [ + "packages/client/*/tests/**/*.host.spec.ts" + ], "references": [ // Shared leaf: web e2e boots the real host webserver (fixture + real-host // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, - // Dual-face host leaf: the node half is the native picking backend, the - // browser half registers the picking flow into ui-workspace's slot — - // client-side Context merges keep it out of the host program. - { "path": "./packages/host/directory-picker-native" }, - { "path": "./packages/host/directory-picker-browse" }, // Compaction seam: the client-runtime pin test value-imports the canonical // checkpoint const from the cordis-free dsh-compact/checkpoint leaf and // deliberately never loads the dsh-compact package root or the host-side @@ -51,9 +52,9 @@ { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, - { "path": "./packages/client/connection" }, + { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, @@ -69,6 +70,8 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-task" }, + { "path": "./packages/client/ui-directory-picker" }, + { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-agent-preset" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 90a254f72a..2bbbbcb1c5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -87,10 +87,17 @@ "website/**/*.ts", "website/.vitepress/**/*.ts" ], + // Under packages/client a test file names the face it covers: `*.client.*` + // belongs to the Client aggregate, `*.host.spec.ts` to this one. The two + // suffixes are mutually exclusive, so each aggregate excludes the other's + // and the package test glob above needs no per-file entry. "exclude": [ - "packages/client/**", - "packages/host/directory-picker-browse/**", - "packages/host/directory-picker-native/**", + "packages/client/*/src/**", + "packages/*/*/tests/**/*.client.ts", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", + "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" @@ -120,7 +127,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -270,11 +277,6 @@ { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-auto" }, - // Dual-face backend leaves stay client-registered (their tests and client - // halves are excluded above); these references only let the adaptive - // chooser's composition test import each backend's NODE entry, whose - // declarations carry no client-side Context merge — the mirror of the - // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, diff --git a/vendor/README.md b/vendor/README.md index b132356bf6..d587daf486 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 573faad38c..19dc11a40a 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,7 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate } from './utils.ts' +import { evaluate, isJsExpr } from './utils.ts' /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -88,15 +88,25 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (this.disabledOf(options)) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (this.disabledOf(entry.options)) return true entry = entry.parent.ctx.fiber.entry } return false } + /** + * Effective disabled state: a `!!js` expression evaluates against the loader + * context. The raw node stays in the options, so write-back keeps the form. + */ + private disabledOf(options: EntryOptions): boolean { + return isJsExpr(options.disabled) + ? Boolean(this.evaluate(options.disabled.__jsExpr)) + : Boolean(options.disabled) + } + evaluate(expr: string) { return evaluate(this.ctx, expr) } diff --git a/website/docs.ts b/website/docs.ts index 2f25ae9d88..3cf1dab74c 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -115,44 +115,28 @@ const homeAndGuide = pairedPages([ }, { source: 'docs/user/guide/index.md', - route: 'guide/index.md', - label: { root: '介绍', en: 'Introduction' }, + route: 'guide/quickstart.md', + label: { root: '使用 Web UI', en: 'Use the Web UI' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 1, sourceAliases: ['docs/user/guide'], }, - { - source: 'docs/user/guide/quickstart.md', - route: 'guide/quickstart.md', - label: { root: '快速开始', en: 'Quick start' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 2, - }, { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 2, }, { source: 'docs/user/guide/python-sdk.md', route: 'guide/python-sdk.md', label: { root: 'Python SDK', en: 'Python SDK' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 4, - }, - { - source: 'docs/user/guide/config.md', - route: 'guide/config.md', - label: { root: '配置文件', en: 'Configuration' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 5, + section: { root: '其他接口', en: 'Other interfaces' }, + order: 1, }, ])